diff --git a/src/client/sw/create-notification.ts b/src/client/sw/create-notification.ts new file mode 100644 index 0000000000..2935eb100c --- /dev/null +++ b/src/client/sw/create-notification.ts @@ -0,0 +1,116 @@ +/* + * Notification manager for SW + */ +declare var self: ServiceWorkerGlobalScope; + +import { getNoteSummary } from '../../misc/get-note-summary'; +import getUserName from '../../misc/get-user-name'; +import { swLang } from '@/sw/lang'; +import { I18n } from '@/scripts/i18n'; +import { pushNotificationData } from '../../types'; + +export async function createNotification(data: pushNotificationData) { + const n = await composeNotification(data); + if (n) return self.registration.showNotification(...n); +} + +async function composeNotification(data: pushNotificationData): Promise<[string, NotificationOptions] | null | undefined> { + if (!swLang.i18n) swLang.fetchLocale(); + const i18n = await swLang.i18n as I18n; + const { t } = i18n; + + switch (data.type) { + /* + case 'driveFileCreated': // TODO (Server Side) + return [t('_notification.fileUploaded'), { + body: data.body.name, + icon: data.body.url, + data + }]; + */ + case 'notification': + switch (data.body.type) { + case 'mention': + return [t('_notification.youGotMention', { name: getUserName(data.body.user) }), { + body: getNoteSummary(data.body.note, i18n.locale), + icon: data.body.user.avatarUrl, + data, + actions: [ + { + action: 'showUser', + title: 'showUser' + } + ] + }]; + + case 'reply': + return [t('_notification.youGotReply', { name: getUserName(data.body.user) }), { + body: getNoteSummary(data.body.note, i18n.locale), + icon: data.body.user.avatarUrl + }]; + + case 'renote': + return [t('_notification.youRenoted', { name: getUserName(data.body.user) }), { + body: getNoteSummary(data.body.note, i18n.locale), + icon: data.body.user.avatarUrl + }]; + + case 'quote': + return [t('_notification.youGotQuote', { name: getUserName(data.body.user) }), { + body: getNoteSummary(data.body.note, i18n.locale), + icon: data.body.user.avatarUrl + }]; + + case 'reaction': + return [`${data.body.reaction} ${getUserName(data.body.user)}`, { + body: getNoteSummary(data.body.note, i18n.locale), + icon: data.body.user.avatarUrl + }]; + + case 'pollVote': + return [t('_notification.youGotPoll', { name: getUserName(data.body.user) }), { + body: getNoteSummary(data.body.note, i18n.locale), + icon: data.body.user.avatarUrl + }]; + + case 'follow': + return [t('_notification.youWereFollowed'), { + body: getUserName(data.body.user), + icon: data.body.user.avatarUrl + }]; + + case 'receiveFollowRequest': + return [t('_notification.youReceivedFollowRequest'), { + body: getUserName(data.body.user), + icon: data.body.user.avatarUrl + }]; + + case 'followRequestAccepted': + return [t('_notification.yourFollowRequestAccepted'), { + body: getUserName(data.body.user), + icon: data.body.user.avatarUrl + }]; + + case 'groupInvited': + return [t('_notification.youWereInvitedToGroup'), { + body: data.body.group.name + }]; + + default: + return null; + } + case 'unreadMessagingMessage': + if (data.body.groupId === null) { + return [t('_notification.youGotMessagingMessageFromUser', { name: getUserName(data.body.user) }), { + icon: data.body.user.avatarUrl, + tag: `messaging:user:${data.body.user.id}` + }]; + } + return [t('_notification.youGotMessagingMessageFromGroup', { name: data.body.group.name }), { + icon: data.body.user.avatarUrl, + tag: `messaging:group:${data.body.group.id}` + }]; + default: + return null; + } +} diff --git a/src/client/sw/lang.ts b/src/client/sw/lang.ts index 21ea53e781..58657f381c 100644 --- a/src/client/sw/lang.ts +++ b/src/client/sw/lang.ts @@ -14,10 +14,9 @@ class SwLang { return prelang; }); - public i18n: I18n | null = null; + public i18n: Promise> | null = null; public setLang(newLang: string) { - this.i18n = null; this.lang = Promise.resolve(newLang); set('lang', newLang); return this.fetchLocale(); @@ -25,18 +24,20 @@ class SwLang { public async fetchLocale() { // Service Workerは何度も起動しそのたびにlocaleを読み込むので、CacheStorageを使う - const localeUrl = `/assets/locales/${await this.lang}.${_VERSION_}.json`; - let localeRes = await caches.match(localeUrl); + return this.i18n = new Promise(async (res, rej) => { + const localeUrl = `/assets/locales/${await this.lang}.${_VERSION_}.json`; + let localeRes = await caches.match(localeUrl); + + if (!localeRes) { + localeRes = await fetch(localeUrl); + const clone = localeRes?.clone(); + if (!clone?.clone().ok) rej('locale fetching error'); - if (!localeRes) { - localeRes = await fetch(localeUrl); - const clone = localeRes?.clone(); - if (!clone?.clone().ok) return; + caches.open(this.cacheName).then(cache => cache.put(localeUrl, clone)); + } - caches.open(this.cacheName).then(cache => cache.put(localeUrl, clone)); - } - - return this.i18n = new I18n(await localeRes.json()); + res(new I18n(await localeRes.json())); + }); } } diff --git a/src/client/sw/notification-read.ts b/src/client/sw/notification-read.ts new file mode 100644 index 0000000000..b4fad05632 --- /dev/null +++ b/src/client/sw/notification-read.ts @@ -0,0 +1,56 @@ +import { get } from "idb-keyval"; +import { pushNotificationData } from '../../types'; + +type Accounts = { + [x: string]: { + queue: string[], + timeout: number | null, + token: string, + } +}; + +class SwNotificationRead { + private accounts: Accounts = {}; + + public async construct() { + const accounts = await get('accounts') as { i: string, id: string }[]; + if (accounts) Error('Account is not recorded'); + + this.accounts = accounts.reduce((acc, e) => { + acc[e.id] = { + queue: [], + timeout: null, + token: e.i, + }; + return acc; + }, {} as Accounts); + + return this; + } + + // プッシュ通知の既読をサーバーに送信 + public async read(data: pushNotificationData) { + if (data.type !== 'notification' || !(data.userId in this.accounts)) return; + + const account = this.accounts[data.userId] + + account.queue.push(data.body.id) + + // 最後の呼び出しから100ms待ってまとめて処理する + if (account.timeout) clearTimeout(account.timeout); + account.timeout = setTimeout(() => { + account.timeout = null; + + console.info(account.token, account.queue) + fetch(`${location.origin}/api/notifications/read`, { + method: 'POST', + body: JSON.stringify({ + i: account.token, + notificationIds: account.queue + }) + }); + }, 100); + } +} + +export const swNotificationRead = (new SwNotificationRead()).construct(); diff --git a/src/client/sw/notification.ts b/src/client/sw/notification.ts deleted file mode 100644 index 1a29844993..0000000000 --- a/src/client/sw/notification.ts +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Notification manager for SW - */ -declare var self: ServiceWorkerGlobalScope; - -import { getNoteSummary } from '../../misc/get-note-summary'; -import getUserName from '../../misc/get-user-name'; -import { swLang } from '@/sw/lang'; - -class SwNotification { - private queue: any[] = []; - - private fetching = false; - - public async append(data) { - if (swLang.i18n) { - const n = await this.composeNotification(data); - if (n) return self.registration.showNotification(...n); - } else { - this.queue.push(data); - if (this.fetching == false) { - this.fetching = true; - await swLang.fetchLocale(); - const promises = this.queue.map(this.composeNotification).map(n => { - if (!n) return; - return self.registration.showNotification(...n); - }); - this.fetching = false; - this.queue = []; - return Promise.all(promises); - } - } - } - - private composeNotification(data): [string, NotificationOptions] | null | undefined { - const { i18n } = swLang; - if (!i18n) return; - const { t } = i18n; - - switch (data.type) { - case 'driveFileCreated': // TODO (Server Side) - return [t('_notification.fileUploaded'), { - body: data.body.name, - icon: data.body.url, - data - }]; - case 'notification': - switch (data.body.type) { - case 'mention': - return [t('_notification.youGotMention', { name: getUserName(data.body.user) }), { - body: getNoteSummary(data.body.note, i18n.locale), - icon: data.body.user.avatarUrl, - data, - actions: [ - { - action: 'showUser', - title: 'showUser' - } - ] - }]; - - case 'reply': - return [t('_notification.youGotReply', { name: getUserName(data.body.user) }), { - body: getNoteSummary(data.body.note, i18n.locale), - icon: data.body.user.avatarUrl - }]; - - case 'renote': - return [t('_notification.youRenoted', { name: getUserName(data.body.user) }), { - body: getNoteSummary(data.body.note, i18n.locale), - icon: data.body.user.avatarUrl - }]; - - case 'quote': - return [t('_notification.youGotQuote', { name: getUserName(data.body.user) }), { - body: getNoteSummary(data.body.note, i18n.locale), - icon: data.body.user.avatarUrl - }]; - - case 'reaction': - return [`${data.body.reaction} ${getUserName(data.body.user)}`, { - body: getNoteSummary(data.body.note, i18n.locale), - icon: data.body.user.avatarUrl - }]; - - case 'pollVote': - return [t('_notification.youGotPoll', { name: getUserName(data.body.user) }), { - body: getNoteSummary(data.body.note, i18n.locale), - icon: data.body.user.avatarUrl - }]; - - case 'follow': - return [t('_notification.youWereFollowed'), { - body: getUserName(data.body.user), - icon: data.body.user.avatarUrl - }]; - - case 'receiveFollowRequest': - return [t('_notification.youReceivedFollowRequest'), { - body: getUserName(data.body.user), - icon: data.body.user.avatarUrl - }]; - - case 'followRequestAccepted': - return [t('_notification.yourFollowRequestAccepted'), { - body: getUserName(data.body.user), - icon: data.body.user.avatarUrl - }]; - - case 'groupInvited': - return [t('_notification.youWereInvitedToGroup'), { - body: data.body.group.name - }]; - - default: - return null; - } - case 'unreadMessagingMessage': - if (data.body.groupId === null) { - return [t('_notification.youGotMessagingMessageFromUser', { name: getUserName(data.body.user) }), { - icon: data.body.user.avatarUrl, - tag: `messaging:user:${data.body.user.id}` - }]; - } - return [t('_notification.youGotMessagingMessageFromGroup', { name: data.body.group.name }), { - icon: data.body.user.avatarUrl, - tag: `messaging:group:${data.body.group.id}` - }]; - default: - return null; - } - } - -} - -export const swNotification = new SwNotification(); diff --git a/src/client/sw/sw.ts b/src/client/sw/sw.ts index 5f9028ec3e..e76a49392d 100644 --- a/src/client/sw/sw.ts +++ b/src/client/sw/sw.ts @@ -3,9 +3,10 @@ */ declare var self: ServiceWorkerGlobalScope; -import { get } from 'idb-keyval'; -import { swNotification } from '@/sw/notification'; +import { createNotification } from '@/sw/create-notification'; import { swLang } from '@/sw/lang'; +import { swNotificationRead } from '@/sw/notification-read'; +import { pushNotificationData } from '../../types'; //#region Variables // const cacheName = `mk-cache-${_VERSION_}`; @@ -55,21 +56,23 @@ self.addEventListener('push', ev => { // // クライアントがあったらストリームに接続しているということなので通知しない // if (clients.length != 0) return; - const data = ev.data?.json(); + const data: pushNotificationData = ev.data?.json(); switch (data.type) { case 'notification': - case 'driveFileCreated': + // case 'driveFileCreated': case 'unreadMessagingMessage': - return swNotification.append(data); + return createNotification(data); case 'readAllNotifications': for (const n of await self.registration.getNotifications()) { n.close(); } break; case 'readNotifications': - for (const n of await self.registration.getNotifications()) { - if (data.notificationIds.includes(n.data.body.id)) n.close(); + for (const notification of await self.registration.getNotifications()) { + if (data.body.notificationIds.includes(notification.data.body.id)) { + notification.close() + }; } break; } @@ -80,7 +83,7 @@ self.addEventListener('push', ev => { //#region Notification self.addEventListener('notificationclick', ev => { const { action, notification } = ev; - const { data } = notification; + const data: pushNotificationData = notification.data; const { origin } = location; const suffix = `?loginId=${data.userId}`; @@ -104,30 +107,17 @@ self.addEventListener('notificationclick', ev => { notification.close(); }); -self.addEventListener('notificationclose', async ev => { +self.addEventListener('notificationclose', ev => { const { notification } = ev; + if (notification.title !== 'notificationclose') { - self.registration.showNotification('notificationclose', { body: `${notification.data.id}` }); + self.registration.showNotification('notificationclose', { body: `${notification.data.body.id}` }); } - const { data } = notification; + const data: pushNotificationData = notification.data; - if (data.isNotification) { - const { origin } = location; - - const accounts = await get('accounts'); - const account = accounts.find(i => i.id === data.userId); - - if (!account) return; - - if (data.type === 'notification') { - fetch(`${origin}/api/notifications/read`, { - method: 'POST', - body: JSON.stringify({ - i: account.token, - notificationIds: [data.body.id] - }) - }); - } + if (data.type === 'notification') { + console.log('close', data); + swNotificationRead.then(that => that.read(data)); } }); //#endregion diff --git a/src/services/push-notification.ts b/src/services/push-notification.ts index f879dc094e..70300de018 100644 --- a/src/services/push-notification.ts +++ b/src/services/push-notification.ts @@ -4,6 +4,7 @@ import { SwSubscriptions } from '../models'; import { fetchMeta } from '../misc/fetch-meta'; import { PackedNotification } from '../models/repositories/notification'; import { PackedMessagingMessage } from '../models/repositories/messaging-message'; +import { pushNotificationData } from '../types'; type pushNotificationsTypes = { 'notification': PackedNotification; @@ -38,7 +39,7 @@ export async function pushNotification(u push.sendNotification(pushSubscription, JSON.stringify({ type, body, userId - }), { + } as pushNotificationData), { proxy: config.proxy }).catch((err: any) => { //swLogger.info(err.statusCode); diff --git a/src/types.ts b/src/types.ts index d8eb442810..e012baf849 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,3 +3,9 @@ export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const; export const mutedNoteReasons = ['word', 'manual', 'spam', 'other'] as const; + +export type pushNotificationData = { + type: 'notification' | 'unreadMessagingMessage' | 'readNotifications' | 'readAllNotifications', + body: any, + userId: string +};