diff --git a/locales/index.d.ts b/locales/index.d.ts index acdc1fc421..fdce0b9db9 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -2120,6 +2120,10 @@ export interface Locale extends ILocale { * アカウント設定 */ "accountSettings": string; + /** + * タイムラインのヘッダー + */ + "timelineHeader": string; /** * プロモーション */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 3ac1ce82a3..2187133f41 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -526,6 +526,7 @@ dayOverDayChanges: "前日比" appearance: "アピアランス" clientSettings: "クライアント設定" accountSettings: "アカウント設定" +timelineHeader: "タイムラインのヘッダー" promotion: "プロモーション" promote: "プロモート" numberOfDays: "日数" diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue index 5fc1fd1bca..d212aaefe6 100644 --- a/packages/frontend/src/pages/settings/index.vue +++ b/packages/frontend/src/pages/settings/index.vue @@ -115,6 +115,11 @@ const menuDef = computed(() => [{ text: i18n.ts.navbar, to: '/settings/navbar', active: currentPage.value?.route.name === 'navbar', + }, { + icon: 'ti ti-layout-navbar', + text: i18n.ts.timelineHeader, + to: '/settings/timelineheader', + active: currentPage.value?.route.name === 'timelineHeader', }, { icon: 'ti ti-equal-double', text: i18n.ts.statusbar, diff --git a/packages/frontend/src/pages/settings/timelineHeader.vue b/packages/frontend/src/pages/settings/timelineHeader.vue new file mode 100644 index 0000000000..9192439bd8 --- /dev/null +++ b/packages/frontend/src/pages/settings/timelineHeader.vue @@ -0,0 +1,149 @@ + + + + + + + diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index 98744c6318..2d88cda853 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -53,6 +53,7 @@ import { deviceKind } from '@/scripts/device-kind.js'; import { deepMerge } from '@/scripts/merge.js'; import { MenuItem } from '@/types/menu.js'; import { miLocalStorage } from '@/local-storage.js'; +import { timelineHeaderItemDef } from '@/timelineHeader.js'; provide('shouldOmitHeaderTitle', true); @@ -277,49 +278,23 @@ const headerActions = computed(() => { } return tmp; }); - -const headerTabs = computed(() => [...(defaultStore.reactiveState.pinnedUserLists.value.map(l => ({ - key: 'list:' + l.id, - title: l.name, - icon: 'ti ti-star', - iconOnly: true, -}))), { - key: 'home', - title: i18n.ts._timelines.home, - icon: 'ti ti-home', - iconOnly: true, -}, ...(isLocalTimelineAvailable ? [{ - key: 'local', - title: i18n.ts._timelines.local, - icon: 'ti ti-planet', - iconOnly: true, -}, { - key: 'social', - title: i18n.ts._timelines.social, - icon: 'ti ti-universe', - iconOnly: true, -}] : []), ...(isGlobalTimelineAvailable ? [{ - key: 'global', - title: i18n.ts._timelines.global, - icon: 'ti ti-whirl', - iconOnly: true, -}] : []), { - icon: 'ti ti-list', - title: i18n.ts.lists, - iconOnly: true, - onClick: chooseList, -}, { - icon: 'ti ti-antenna', - title: i18n.ts.antennas, - iconOnly: true, - onClick: chooseAntenna, -}, { - icon: 'ti ti-device-tv', - title: i18n.ts.channel, - iconOnly: true, - onClick: chooseChannel, -}] as Tab[]); - +let headerTabs = computed(() => defaultStore.reactiveState.timelineTopBar.value.map(tab => ({ + ...(tab !== 'lists' && tab !== 'antennas' && tab !== 'channels' ? { + key: tab, + } : {}), + title: timelineHeaderItemDef[tab].title, + icon: timelineHeaderItemDef[tab].icon, + iconOnly: timelineHeaderItemDef[tab].iconOnly, + ...(tab === 'lists' ? { + onClick: (ev) => chooseList(ev), + } : {}), + ...(tab === 'antennas' ? { + onClick: (ev) => chooseAntenna(ev), + } : {}), + ...(tab === 'channels' ? { + onClick: (ev) => chooseChannel(ev), + } : {}), +})) as Tab[]); const headerTabsWhenNotLogin = computed(() => [ ...(isLocalTimelineAvailable ? [{ key: 'local', diff --git a/packages/frontend/src/router/definition.ts b/packages/frontend/src/router/definition.ts index 8a443f627b..b63a9e7509 100644 --- a/packages/frontend/src/router/definition.ts +++ b/packages/frontend/src/router/definition.ts @@ -112,6 +112,10 @@ const routes: RouteDef[] = [{ path: '/navbar', name: 'navbar', component: page(() => import('@/pages/settings/navbar.vue')), + }, { + path: '/timelineheader', + name: 'timelineHeader', + component: page(() => import('@/pages/settings/timelineHeader.vue')), }, { path: '/statusbar', name: 'statusbar', diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index e8eb5a1ed7..09a1f13f72 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -52,7 +52,8 @@ export type SoundStore = { volume: number; } - +export const isLocalTimelineAvailable = ($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable); +export const isGlobalTimelineAvailable = ($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable); export const postFormActions: PostFormAction[] = []; export const userActions: UserAction[] = []; export const noteActions: NoteAction[] = []; @@ -148,6 +149,22 @@ export const defaultStore = markRaw(new Storage('base', { 'ui', ], }, + timelineTopBar: { + where: 'deviceAccount', + default: [ + 'home', + ...(isLocalTimelineAvailable ? [ + 'local', + 'social', + ] : []), + ...(isGlobalTimelineAvailable ? [ + 'global', + ] : []), + 'lists', + 'antennas', + 'channels', + ], + }, visibility: { where: 'deviceAccount', default: 'public' as (typeof Misskey.noteVisibilities)[number], @@ -522,6 +539,8 @@ interface Watcher { */ import lightTheme from '@/themes/l-light.json5'; import darkTheme from '@/themes/d-green-lime.json5'; +import { $i } from '@/account.js'; +import { instance } from '@/instance.js'; export class ColdDeviceStorage { public static default = { diff --git a/packages/frontend/src/timelineHeader.ts b/packages/frontend/src/timelineHeader.ts new file mode 100644 index 0000000000..490a9442c8 --- /dev/null +++ b/packages/frontend/src/timelineHeader.ts @@ -0,0 +1,117 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { reactive } from 'vue'; +import { i18n } from '@/i18n.js'; +import { antennasCache, favoritedChannelsCache, userListsCache } from '@/cache.js'; +import { MenuItem } from '@/types/menu.js'; +import * as os from '@/os.js'; +import { miLocalStorage } from '@/local-storage.js'; +import { isLocalTimelineAvailable, isGlobalTimelineAvailable } from '@/store.js'; + +export const timelineHeaderItemDef = reactive({ + home: { + title: i18n.ts._timelines.home, + icon: 'ti ti-home', + iconOnly: false, + }, + ...(isLocalTimelineAvailable ? { + local: { + key: 'local', + title: i18n.ts._timelines.local, + icon: 'ti ti-planet', + iconOnly: true, + }, + social: { + title: i18n.ts._timelines.social, + icon: 'ti ti-universe', + iconOnly: true, + } } : {}), + ...(isGlobalTimelineAvailable ? { global: { + key: 'global', + title: i18n.ts._timelines.global, + icon: 'ti ti-whirl', + iconOnly: true, + } } : {}), + lists: { + icon: 'ti ti-list', + title: i18n.ts.lists, + iconOnly: true, + }, + antennas: { + icon: 'ti ti-antenna', + title: i18n.ts.antennas, + iconOnly: true, + }, + channels: { + icon: 'ti ti-device-tv', + title: i18n.ts.channel, + iconOnly: true, + }, +}); + +async function chooseList(ev: MouseEvent): Promise { + const lists = await userListsCache.fetch(); + const items: MenuItem[] = [ + ...lists.map(list => ({ + type: 'link' as const, + text: list.name, + to: `/timeline/list/${list.id}`, + })), + (lists.length === 0 ? undefined : { type: 'divider' }), + { + type: 'link' as const, + icon: 'ti ti-plus', + text: i18n.ts.createNew, + to: '/my/lists', + }, + ]; + os.popupMenu(items, ev.currentTarget ?? ev.target); +} + +async function chooseAntenna(ev: MouseEvent): Promise { + const antennas = await antennasCache.fetch(); + const items: MenuItem[] = [ + ...antennas.map(antenna => ({ + type: 'link' as const, + text: antenna.name, + indicate: antenna.hasUnreadNote, + to: `/timeline/antenna/${antenna.id}`, + })), + (antennas.length === 0 ? undefined : { type: 'divider' }), + { + type: 'link' as const, + icon: 'ti ti-plus', + text: i18n.ts.createNew, + to: '/my/antennas', + }, + ]; + os.popupMenu(items, ev.currentTarget ?? ev.target); +} + +async function chooseChannel(ev: MouseEvent): Promise { + const channels = await favoritedChannelsCache.fetch(); + const items: MenuItem[] = [ + ...channels.map(channel => { + const lastReadedAt = miLocalStorage.getItemAsJson(`channelLastReadedAt:${channel.id}`) ?? null; + const hasUnreadNote = (lastReadedAt && channel.lastNotedAt) ? Date.parse(channel.lastNotedAt) > lastReadedAt : !!(!lastReadedAt && channel.lastNotedAt); + + return { + type: 'link' as const, + text: channel.name, + indicate: hasUnreadNote, + to: `/channels/${channel.id}`, + }; + }), + (channels.length === 0 ? undefined : { type: 'divider' }), + { + type: 'link' as const, + icon: 'ti ti-plus', + text: i18n.ts.createNew, + to: '/channels', + }, + ]; + os.popupMenu(items, ev.currentTarget ?? ev.target); +}