diff --git a/locales/en-US.yml b/locales/en-US.yml index 80e45c42d..e22b92e2f 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 e407d2119..422f1c842 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 57f52c64b..e20907e6b 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 1e4e815d5..c96d803d1 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 }} + @@ -337,6 +339,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 f180e0b72..86b8debe3 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 000000000..3a7887bca --- /dev/null +++ b/packages/frontend/src/scripts/favicon-dot.ts @@ -0,0 +1,75 @@ +class FavIconDot { + canvas : HTMLCanvasElement; + src : string | null = null; + ctx : CanvasRenderingContext2D | null = null; + favconImage : HTMLImageElement | null = null; + faviconEL : HTMLLinkElement; + hasLoaded : Promise; + + constructor() { + this.canvas = document.createElement('canvas'); + this.faviconEL = document.querySelector('link[rel$=icon]') ?? this._createFaviconElem(); + + this.src = this.faviconEL.getAttribute('href'); + this.ctx = this.canvas.getContext('2d'); + + this.favconImage = document.createElement('img'); + this.hasLoaded = new Promise((resolve, _reject) => { + if (this.favconImage != null) { + this.favconImage.onload = () => { + this.canvas.width = (this.favconImage as HTMLImageElement).width; + this.canvas.height = (this.favconImage as HTMLImageElement).height; + // resolve(); + setTimeout(() => resolve(), 200); + }; + } + }); + this.favconImage.src = this.faviconEL.href; + } + + private _createFaviconElem() { + const newLink = document.createElement('link'); + newLink.rel = 'icon'; + newLink.href = '/favicon.ico'; + document.head.appendChild(newLink); + return newLink; + } + + private _drawIcon() { + if (!this.ctx || !this.favconImage) return; + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + this.ctx.drawImage(this.favconImage, 0, 0, this.favconImage.width, this.favconImage.height); + } + + private _drawDot() { + if (!this.ctx || !this.favconImage) return; + this.ctx.beginPath(); + this.ctx.arc(this.favconImage.width - 10, 10, 10, 0, 2 * Math.PI); + this.ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--navIndicator'); + this.ctx.strokeStyle = 'white'; + this.ctx.fill(); + this.ctx.stroke(); + } + + private _setFavicon() { + this.faviconEL.href = this.canvas.toDataURL('image/png'); + } + + async setVisible(isVisible : boolean) { + //Wait for it to have loaded the icon + await this.hasLoaded; + console.log(this.hasLoaded); + this._drawIcon(); + if (isVisible) this._drawDot(); + this._setFavicon(); + } +} + +let icon: FavIconDot = new FavIconDot(); + +export function setFavIconDot(visible: boolean) { + if (!icon) { + icon = new FavIconDot(); + } + icon.setVisible(visible); +} diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 2cf17b27c..7f6377613 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 4fe53ae6a..63b19dfb2 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