diff --git a/locales/en-US.yml b/locales/en-US.yml index edf4abfac1..387a39b54a 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -688,6 +688,7 @@ channel: "Channels" create: "Create" notificationSetting: "Notification settings" notificationSettingDesc: "Select the types of notification to display." +enableFaviconNotificationDot: "Enable favicon notification dot" useGlobalSetting: "Use global settings" useGlobalSettingDesc: "If turned on, your account's notification settings will be used. If turned off, individual configurations can be made." other: "Other" diff --git a/locales/index.d.ts b/locales/index.d.ts index 6efd5b33b9..0ba00bf44c 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -2764,6 +2764,10 @@ export interface Locale extends ILocale { * 表示する通知の種別を選択してください。 */ "notificationSettingDesc": string; + /** + * ファビコン通知ドットを有効にする + */ + "enableFaviconNotificationDot": string; /** * グローバル設定を使う */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 68e4091a88..27af6235d9 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -687,6 +687,7 @@ channel: "チャンネル" create: "作成" notificationSetting: "通知設定" notificationSettingDesc: "表示する通知の種別を選択してください。" +enableFaviconNotificationDot: "ファビコン通知ドットを有効にする" useGlobalSetting: "グローバル設定を使う" useGlobalSettingDesc: "オンにすると、アカウントの通知設定が使用されます。オフにすると、個別に設定できるようになります。" other: "その他" diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue index f404e3265f..76081666d8 100644 --- a/packages/frontend/src/pages/settings/general.vue +++ b/packages/frontend/src/pages/settings/general.vue @@ -112,6 +112,8 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.useGroupedNotifications }} + {{ i18n.ts.enableFaviconNotificationDot }} + @@ -345,6 +347,7 @@ const oneko = computed(defaultStore.makeGetterSetter('oneko')); const loadRawImages = computed(defaultStore.makeGetterSetter('loadRawImages')); const highlightSensitiveMedia = computed(defaultStore.makeGetterSetter('highlightSensitiveMedia')); const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab')); +const enableFaviconNotificationDot = computed(defaultStore.makeGetterSetter('enableFaviconNotificationDot')); const warnMissingAltText = computed(defaultStore.makeGetterSetter('warnMissingAltText')); const nsfw = computed(defaultStore.makeGetterSetter('nsfw')); const showFixedPostForm = computed(defaultStore.makeGetterSetter('showFixedPostForm')); diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue index 07f5b05a60..d4349b466d 100644 --- a/packages/frontend/src/pages/settings/preferences-backups.vue +++ b/packages/frontend/src/pages/settings/preferences-backups.vue @@ -72,6 +72,7 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [ 'advancedMfm', 'loadRawImages', 'warnMissingAltText', + 'enableFaviconNotificationDot', 'imageNewTab', 'dataSaver', 'disableShowingAnimatedImages', diff --git a/packages/frontend/src/scripts/favicon-dot.ts b/packages/frontend/src/scripts/favicon-dot.ts new file mode 100644 index 0000000000..e338f55f72 --- /dev/null +++ b/packages/frontend/src/scripts/favicon-dot.ts @@ -0,0 +1,114 @@ +import tinycolor from 'tinycolor2'; + +class FavIconDot { + canvas: HTMLCanvasElement; + src: string | null = null; + ctx: CanvasRenderingContext2D | null = null; + faviconImage: HTMLImageElement | null = null; + faviconEL: HTMLLinkElement | undefined; + hasLoaded: Promise | undefined; + + constructor() { + this.canvas = document.createElement('canvas'); + } + + /** + * Must be called before calling any other functions + */ + public async setup() { + const element: HTMLLinkElement = await this.getOrMakeFaviconElement(); + + this.faviconEL = element; + this.src = this.faviconEL.getAttribute('href'); + this.ctx = this.canvas.getContext('2d'); + + this.faviconImage = document.createElement('img'); + + this.hasLoaded = new Promise((resolve, reject) => { + (this.faviconImage as HTMLImageElement).addEventListener('load', () => { + this.canvas.width = (this.faviconImage as HTMLImageElement).width; + this.canvas.height = (this.faviconImage as HTMLImageElement).height; + resolve(); + }); + (this.faviconImage as HTMLImageElement).addEventListener('error', () => { + reject('Failed to create favicon img element'); + }); + }); + + this.faviconImage.src = this.faviconEL.href; + } + + private async getOrMakeFaviconElement(): Promise { + return new Promise((resolve, reject) => { + const favicon = (document.querySelector('link[rel=icon]') ?? this.createFaviconElem()) as HTMLLinkElement; + favicon.addEventListener('load', () => { + resolve(favicon); + }); + + favicon.onerror = () => { + reject('Failed to load favicon'); + }; + resolve(favicon); + }); + } + + private createFaviconElem() { + const newLink = document.createElement('link'); + newLink.setAttribute('rel', 'icon'); + newLink.setAttribute('href', '/favicon.ico'); + newLink.setAttribute('type', 'image/x-icon'); + + document.head.appendChild(newLink); + return newLink; + } + + private drawIcon() { + if (!this.ctx || !this.faviconImage) return; + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + this.ctx.drawImage(this.faviconImage, 0, 0, this.faviconImage.width, this.faviconImage.height); + } + + private drawDot() { + if (!this.ctx || !this.faviconImage) return; + this.ctx.beginPath(); + this.ctx.arc(this.faviconImage.width - 10, 10, 10, 0, 2 * Math.PI); + const computedStyle = getComputedStyle(document.documentElement); + this.ctx.fillStyle = tinycolor(computedStyle.getPropertyValue('--navIndicator')).toHexString(); + this.ctx.strokeStyle = 'white'; + this.ctx.fill(); + this.ctx.stroke(); + } + + private setFavicon() { + if (this.faviconEL) this.faviconEL.href = this.canvas.toDataURL('image/png'); + } + + async setVisible(isVisible: boolean) { + // Wait for it to have loaded the icon + await this.hasLoaded; + this.drawIcon(); + if (isVisible) this.drawDot(); + this.setFavicon(); + } +} + +let icon: FavIconDot | undefined = undefined; + +export function setFavIconDot(visible: boolean) { + const setIconVisibility = async () => { + if (!icon) { + icon = new FavIconDot(); + await icon.setup(); + } + + (icon as FavIconDot).setVisible(visible); + }; + + // If document is already loaded, set visibility immediately + if (document.readyState === 'complete') { + setIconVisibility(); + } else { + // Otherwise, set visibility when window loads + window.addEventListener('load', setIconVisibility); + } +} diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 2cf17b27c5..7f6377613e 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -268,6 +268,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: true, }, + enableFaviconNotificationDot: { + where: 'device', + default: true, + }, imageNewTab: { where: 'device', default: false, diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue index 4fe53ae6a3..b1fe8e54fc 100644 --- a/packages/frontend/src/ui/_common_/common.vue +++ b/packages/frontend/src/ui/_common_/common.vue @@ -47,8 +47,9 @@ SPDX-License-Identifier: AGPL-3.0-only