diff --git a/locales/index.d.ts b/locales/index.d.ts index 360bec4235d3..88ed4ef257da 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -9992,6 +9992,32 @@ export interface Locale extends ILocale { * これらの機能はtaiymeで独自実装したものです。 */ readonly "description": string; + readonly "_tickerPosition": { + /** + * インスタンス情報の表示位置 + */ + readonly "label": string; + /** + * デフォルト + */ + readonly "default": string; + /** + * 縦 (左端) + */ + readonly "leftVerticalBar": string; + /** + * 縦 (右端) + */ + readonly "rightVerticalBar": string; + /** + * 透かし (左下) + */ + readonly "leftWatermark": string; + /** + * 透かし (右下) + */ + readonly "rightWatermark": string; + }; readonly "_superMenuDisplayMode": { /** * 設定メニューの表示モード diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 762a0698cf10..548b4b30581f 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2663,6 +2663,13 @@ _tms: _settings: title: "taiyme拡張機能" description: "これらの機能はtaiymeで独自実装したものです。" + _tickerPosition: + label: "インスタンス情報の表示位置" + default: "デフォルト" + leftVerticalBar: "縦 (左端)" + rightVerticalBar: "縦 (右端)" + leftWatermark: "透かし (左下)" + rightWatermark: "透かし (右下)" _superMenuDisplayMode: label: "設定メニューの表示モード" caption: "主にスマートフォン・タブレットデバイス向けの設定です。" diff --git a/packages/frontend/src/components/MkInstanceTicker.vue b/packages/frontend/src/components/MkInstanceTicker.vue deleted file mode 100644 index f8e711a176f2..000000000000 --- a/packages/frontend/src/components/MkInstanceTicker.vue +++ /dev/null @@ -1,86 +0,0 @@ - - - - - - - diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index d33120b5a450..4ddc4e475d99 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -17,7 +17,6 @@ SPDX-License-Identifier: AGPL-3.0-only
-
@@ -48,11 +47,10 @@ SPDX-License-Identifier: AGPL-3.0-only
-
- +

@@ -175,7 +173,7 @@ import MkCwButton from '@/components/MkCwButton.vue'; import MkPoll from '@/components/MkPoll.vue'; import MkUsersTooltip from '@/components/MkUsersTooltip.vue'; import MkUrlPreview from '@/components/MkUrlPreview.vue'; -import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; +import TmsInstanceTicker from '@/components/TmsInstanceTicker.vue'; import { pleaseLogin } from '@/scripts/please-login.js'; import { focusPrev, focusNext } from '@/scripts/focus.js'; import { checkWordMute } from '@/scripts/check-word-mute.js'; @@ -265,7 +263,7 @@ const muted = ref(checkMute(appearNote.value, $i?.mutedWords)); const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true)); const translation = ref(null); const translating = ref(false); -const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance); +const showTicker = computed(() => (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance != null) || (appearNote.value.channel != null)); const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id)); const canPakuru = computed(() => tmsStore.reactiveState.enablePakuru.value || tmsStore.reactiveState.enableNumberquote.value); const renoteCollapsed = ref( @@ -681,10 +679,6 @@ function emitUpdReaction(emoji: string, delta: number) { & + .article { padding-top: 8px; } - - > .colorBar { - height: calc(100% - 6px); - } } .renoteAvatar { @@ -760,16 +754,6 @@ function emitUpdReaction(emoji: string, delta: number) { padding: 28px 32px; } -.colorBar { - position: absolute; - top: 8px; - left: 8px; - width: 5px; - height: calc(100% - 16px); - border-radius: 999px; - pointer-events: none; -} - .avatar { flex-shrink: 0; display: block !important; @@ -999,13 +983,6 @@ function emitUpdReaction(emoji: string, delta: number) { } } } - - .colorBar { - top: 6px; - left: 6px; - width: 4px; - height: calc(100% - 12px); - } } @container (max-width: 300px) { diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index fc8f65f33b13..4a7f0cc7cb77 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -59,10 +59,11 @@ SPDX-License-Identifier: AGPL-3.0-only +

- +
@@ -206,7 +207,7 @@ import MkCwButton from '@/components/MkCwButton.vue'; import MkPoll from '@/components/MkPoll.vue'; import MkUsersTooltip from '@/components/MkUsersTooltip.vue'; import MkUrlPreview from '@/components/MkUrlPreview.vue'; -import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; +import TmsInstanceTicker from '@/components/TmsInstanceTicker.vue'; import { pleaseLogin } from '@/scripts/please-login.js'; import { checkWordMute } from '@/scripts/check-word-mute.js'; import { userPage } from '@/filters/user.js'; @@ -283,7 +284,7 @@ const translation = ref(null); const translating = ref(false); const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null; const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.value.renote?.url !== url && appearNote.value.renote?.uri !== url) : null; -const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance); +const showTicker = computed(() => (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance != null) || (appearNote.value.channel != null)); const conversation = ref([]); const replies = ref([]); const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i?.id); diff --git a/packages/frontend/src/components/TmsInstanceTicker.vue b/packages/frontend/src/components/TmsInstanceTicker.vue new file mode 100644 index 000000000000..a23736221680 --- /dev/null +++ b/packages/frontend/src/components/TmsInstanceTicker.vue @@ -0,0 +1,201 @@ + + + + + + + diff --git a/packages/frontend/src/pages/tms/settings/index.main.vue b/packages/frontend/src/pages/tms/settings/index.main.vue index a9f9761d6afe..4da5d1342619 100644 --- a/packages/frontend/src/pages/tms/settings/index.main.vue +++ b/packages/frontend/src/pages/tms/settings/index.main.vue @@ -8,6 +8,14 @@ SPDX-License-Identifier: AGPL-3.0-only
+ + + + + + + + @@ -64,6 +72,13 @@ const pullToRefreshAllReload = computed(tmsStore.makeGetterSetter('pullToRefresh //#endregion //#region 即時変更 (ダイアログ付き) +const tickerPosition = computed({ + get: () => tmsStore.reactiveState.tickerPosition.value, + set: (newValue) => { + tmsStore.set('tickerPosition', newValue); + reloadAsk(); + }, +}); const pullToRefreshSensitivity = computed({ get: () => tmsStore.reactiveState.pullToRefreshSensitivity.value, set: (newValue) => { diff --git a/packages/frontend/src/scripts/tms/color.ts b/packages/frontend/src/scripts/tms/color.ts new file mode 100644 index 000000000000..9251dae3b4dd --- /dev/null +++ b/packages/frontend/src/scripts/tms/color.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export type HEX = string; + +export type RGB = { + readonly r: number; + readonly g: number; + readonly b: number; +}; + +const DEFAULT_HEX = '#ff0000' as const satisfies HEX; +const DEFAULT_RGB = { r: 255, g: 0, b: 0 } as const satisfies RGB; + +export const hexToRgb = (hex: string): RGB => { + if (hex.startsWith('#')) { + hex = hex.slice(1); + } + if (hex.length === 3) { + if (!validHex(hex)) { + return DEFAULT_RGB; + } + hex = [...hex].map(char => char.repeat(2)).join(''); + } + if (!(hex.length === 6 && validHex(hex))) { + return DEFAULT_RGB; + } + const [r, g, b] = Array.from(hex.match(/.{2}/g) ?? [], n => parseInt(n, 16)); + return { r, g, b } as const satisfies RGB; +}; + +export const rgbToHex = (rgb: RGB): HEX => { + if (!validRgb(rgb)) { + return DEFAULT_HEX; + } + const toHex2Digit = (n: number): string => { + return (n.toString(16).split('.').at(0) ?? '').padStart(2, '0'); + }; + const { r, g, b } = rgb; + const hexR = toHex2Digit(r); + const hexG = toHex2Digit(g); + const hexB = toHex2Digit(b); + return `#${hexR}${hexG}${hexB}` as const satisfies HEX; +}; + +const validHex = (hex: unknown): hex is string => { + if (typeof hex !== 'string') return false; + return /^[0-9a-f]+$/i.test(hex); +}; + +const validRgb = (rgb: unknown): rgb is RGB => { + if (typeof rgb !== 'object' || rgb == null) return false; + if (!('r' in rgb && 'g' in rgb && 'b' in rgb)) return false; + const validRange = (n: unknown): boolean => { + if (typeof n !== 'number') return false; + if (!Number.isInteger(n)) return false; + return 0 <= n && n <= 255; + }; + const { r, g, b } = rgb; + return validRange(r) && validRange(g) && validRange(b); +}; diff --git a/packages/frontend/src/scripts/tms/instance-ticker.ts b/packages/frontend/src/scripts/tms/instance-ticker.ts new file mode 100644 index 000000000000..f4cb9d562ffa --- /dev/null +++ b/packages/frontend/src/scripts/tms/instance-ticker.ts @@ -0,0 +1,99 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { host } from '@/config.js'; +import { instance as localInstance } from '@/instance.js'; +import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; +import { type HEX, hexToRgb } from '@/scripts/tms/color.js'; +import { type TickerProps } from '@/components/TmsInstanceTicker.vue'; + +//#region ticker info +type TickerInfo = { + readonly name: string; + readonly iconUrl: string; + readonly themeColor: string; +}; + +const TICKER_BG_COLOR_DEFAULT = '#777777' as const; + +export const getTickerInfo = (props: TickerProps): TickerInfo => { + if (props.channel != null) { + return { + name: props.channel.name, + iconUrl: getProxiedIconUrl(localInstance) ?? '/favicon.ico', + themeColor: props.channel.color, + } as const satisfies TickerInfo; + } + if (props.instance != null) { + return { + name: props.instance.name ?? '', + iconUrl: getProxiedIconUrl(props.instance) ?? '/client-assets/dummy.png', + themeColor: props.instance.themeColor ?? TICKER_BG_COLOR_DEFAULT, + } as const satisfies TickerInfo; + } + return { + name: localInstance.name ?? host, + iconUrl: getProxiedIconUrl(localInstance) ?? '/favicon.ico', + themeColor: localInstance.themeColor ?? document.querySelector('meta[name="theme-color-orig"]')?.content ?? TICKER_BG_COLOR_DEFAULT, + } as const satisfies TickerInfo; +}; + +const getProxiedIconUrl = (instance: NonNullable): string | null => { + return getProxiedImageUrlNullable(instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? null; +}; +//#endregion ticker info + +//#region ticker colors +type TickerColors = { + readonly '--ticker-bg': string; + readonly '--ticker-fg': string; + readonly '--ticker-bg-rgb': string; +}; + +const TICKER_YUV_THRESHOLD = 191 as const; +const TICKER_FG_COLOR_LIGHT = '#ffffff' as const; +const TICKER_FG_COLOR_DARK = '#2f2f2fcc' as const; + +const tickerColorsCache = new Map(); + +export const getTickerColors = (info: TickerInfo): TickerColors => { + const bgHex = info.themeColor; + const cachedTickerColors = tickerColorsCache.get(bgHex); + if (cachedTickerColors != null) return cachedTickerColors; + + const { r, g, b } = hexToRgb(bgHex); + const yuv = 0.299 * r + 0.587 * g + 0.114 * b; + const fgHex = yuv > TICKER_YUV_THRESHOLD ? TICKER_FG_COLOR_DARK : TICKER_FG_COLOR_LIGHT; + + const tickerColors = { + '--ticker-fg': fgHex, + '--ticker-bg': bgHex, + '--ticker-bg-rgb': `${r}, ${g}, ${b}`, + } as const satisfies TickerColors; + + tickerColorsCache.set(bgHex, tickerColors); + + return tickerColors; +}; +//#endregion ticker colors + +//#region ticker state +type TickerState = { + readonly normal: boolean; + readonly vertical: boolean; + readonly watermark: boolean; + readonly left: boolean; + readonly right: boolean; +}; + +export const getTickerState = (props: TickerProps): TickerState => { + const vertical = props.position === 'leftVerticalBar' || props.position === 'rightVerticalBar'; + const watermark = props.position === 'leftWatermark' || props.position === 'rightWatermark'; + const normal = !vertical && !watermark; + const left = props.position === 'leftVerticalBar' || props.position === 'leftWatermark'; + const right = props.position === 'rightVerticalBar' || props.position === 'rightWatermark'; + return { normal, vertical, watermark, left, right } as const satisfies TickerState; +}; +//#endregion ticker state diff --git a/packages/frontend/src/tms/store.ts b/packages/frontend/src/tms/store.ts index 6710567f7014..b8bcd0488097 100644 --- a/packages/frontend/src/tms/store.ts +++ b/packages/frontend/src/tms/store.ts @@ -10,6 +10,10 @@ import { Storage } from '@/pizzax.js'; * tmsStore -- 独自実装した機能についてのデータを格納する */ export const tmsStore = markRaw(new Storage('tmsMain', { + tickerPosition: { + where: 'account', + default: 'default' as 'default' | 'leftVerticalBar' | 'rightVerticalBar' | 'leftWatermark' | 'rightWatermark', + }, superMenuDisplayMode: { where: 'deviceAccount', default: 'default' as 'default' | 'classic' | 'forceList',