diff --git a/packages/backend/src/server/api/common/read-notification.ts b/packages/backend/src/server/api/common/read-notification.ts index f2cf495af..17dd8e6f0 100644 --- a/packages/backend/src/server/api/common/read-notification.ts +++ b/packages/backend/src/server/api/common/read-notification.ts @@ -38,8 +38,10 @@ export async function readNotificationByQuery( function postReadAllNotifications(userId: User['id']) { publishMainStream(userId, 'readAllNotifications'); + return pushNotification(userId, 'readAllNotifications', undefined); } function postReadNotifications(userId: User['id'], notificationIds: Notification['id'][]) { publishMainStream(userId, 'readNotifications', notificationIds); + return pushNotification(userId, 'readNotifications', { notificationIds }); } diff --git a/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts b/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts index ea84bd9b3..d169afbb3 100644 --- a/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts +++ b/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts @@ -29,4 +29,5 @@ export default define(meta, paramDef, async (ps, user) => { // 全ての通知を読みましたよというイベントを発行 publishMainStream(user.id, 'readAllNotifications'); + pushNotification(user.id, 'readAllNotifications', undefined); }); diff --git a/packages/backend/src/server/api/endpoints/notifications/read.ts b/packages/backend/src/server/api/endpoints/notifications/read.ts index 6b8fc8c8d..7bce525a5 100644 --- a/packages/backend/src/server/api/endpoints/notifications/read.ts +++ b/packages/backend/src/server/api/endpoints/notifications/read.ts @@ -20,14 +20,30 @@ export const meta = { } as const; export const paramDef = { + oneOf: [ + { type: 'object', properties: { notificationId: { type: 'string', format: 'misskey:id' }, }, required: ['notificationId'], + }, + { + type: 'object', + properties: { + notificationIds: { + type: 'array', + items: { type: 'string', format: 'misskey:id' }, + maxItems: 100, + }, + }, + required: ['notificationIds'], + }, + ], } as const; // eslint-disable-next-line import/no-default-export export default define(meta, paramDef, async (ps, user) => { - return readNotification(user.id, [ps.notificationId]); + if ('notificationId' in ps) return readNotification(user.id, [ps.notificationId]); + return readNotification(user.id, ps.notificationIds); }); diff --git a/packages/backend/src/services/push-notification.ts b/packages/backend/src/services/push-notification.ts index d674271cb..5c3bafbb3 100644 --- a/packages/backend/src/services/push-notification.ts +++ b/packages/backend/src/services/push-notification.ts @@ -60,8 +60,6 @@ export async function pushNotification(u }, }; - //console.log(`send: ${subscription.endpoint?.substring(0, 64)}`); - //console.log(`send: ${type} ${JSON.stringify(body)}`); push.sendNotification(pushSubscription, JSON.stringify({ type, body: type === 'notification' ? truncateNotification(body as Packed<'Notification'>) : body, @@ -69,9 +67,9 @@ export async function pushNotification(u }), { proxy: config.proxy, }).catch((err: any) => { - //console.log(err.statusCode); - //console.log(err.headers); - //console.log(err.body); + //swLogger.info(err.statusCode); + //swLogger.info(err.headers); + //swLogger.info(err.body); if (err.statusCode === 410) { SwSubscriptions.delete({ diff --git a/packages/sw/src/scripts/create-notification.ts b/packages/sw/src/scripts/create-notification.ts index fb4221fdb..7c95e8e41 100644 --- a/packages/sw/src/scripts/create-notification.ts +++ b/packages/sw/src/scripts/create-notification.ts @@ -21,6 +21,7 @@ export async function createNotification(data // users/showの型定義をswos.apiへ当てはめるのが困難なのでapiFetch.requestを直接使用 const account = await getAccountFromId(data.userId); if (!account) return null; + const userDetail = await cli.request('users/show', { userId: data.body.userId }, account.token); return [t('_notification.youWereFollowed'), { body: getUserName(data.body.user), icon: data.body.user.avatarUrl, badge: iconUrl('user-plus'), data, + actions: userDetail.isFollowing ? [] : [ + { + action: 'follow', + title: t('_notification._actions.followBack') + } + ], }]; case 'mention': @@ -56,6 +64,12 @@ async function composeNotification(data icon: data.body.user.avatarUrl, badge: iconUrl('at'), data, + actions: [ + { + action: 'reply', + title: t('_notification._actions.reply') + } + ], }]; case 'reply': @@ -64,6 +78,12 @@ async function composeNotification(data icon: data.body.user.avatarUrl, badge: iconUrl('reply'), data, + actions: [ + { + action: 'reply', + title: t('_notification._actions.reply') + } + ], }]; case 'renote': @@ -72,6 +92,12 @@ async function composeNotification(data icon: data.body.user.avatarUrl, badge: iconUrl('retweet'), data, + actions: [ + { + action: 'showUser', + title: getUserName(data.body.user) + } + ], }]; case 'quote': @@ -80,6 +106,18 @@ async function composeNotification(data icon: data.body.user.avatarUrl, badge: iconUrl('quote-right'), data, + actions: [ + { + action: 'reply', + title: t('_notification._actions.reply') + }, + ...((data.body.note.visibility === 'public' || data.body.note.visibility === 'home') ? [ + { + action: 'renote', + title: t('_notification._actions.renote') + } + ] : []) + ], }]; case 'reaction': @@ -122,6 +160,12 @@ async function composeNotification(data icon: data.body.user.avatarUrl, badge, data, + actions: [ + { + action: 'showUser', + title: getUserName(data.body.user) + } + ], }]; case 'pollVote': @@ -145,6 +189,16 @@ async function composeNotification(data icon: data.body.user.avatarUrl, badge: iconUrl('clock'), data, + actions: [ + { + action: 'accept', + title: t('accept') + }, + { + action: 'reject', + title: t('reject') + } + ], }]; case 'followRequestAccepted': @@ -160,6 +214,16 @@ async function composeNotification(data body: data.body.invitation.group.name, badge: iconUrl('id-card-alt'), data, + actions: [ + { + action: 'accept', + title: t('accept') + }, + { + action: 'reject', + title: t('reject') + } + ], }]; case 'app': @@ -193,3 +257,33 @@ async function composeNotification(data return null; } } + +export async function createEmptyNotification() { + return new Promise(async res => { + if (!swLang.i18n) swLang.fetchLocale(); + const i18n = await swLang.i18n as I18n; + const { t } = i18n; + + await self.registration.showNotification( + t('_notification.emptyPushNotificationMessage'), + { + silent: true, + badge: iconUrl('null'), + tag: 'read_notification', + } + ); + + res(); + + setTimeout(async () => { + for (const n of + [ + ...(await self.registration.getNotifications({ tag: 'user_visible_auto_notification' })), + ...(await self.registration.getNotifications({ tag: 'read_notification' })) + ] + ) { + n.close(); + } + }, 1000); + }); +} diff --git a/packages/sw/src/scripts/notification-read.ts b/packages/sw/src/scripts/notification-read.ts new file mode 100644 index 000000000..5c1de8908 --- /dev/null +++ b/packages/sw/src/scripts/notification-read.ts @@ -0,0 +1,60 @@ +declare var self: ServiceWorkerGlobalScope; + +import { get } from 'idb-keyval'; +import { pushNotificationDataMap } from '@/types'; +import { api } from '@/scripts/operations'; + +type Accounts = { + [x: string]: { + queue: string[], + timeout: number | null + } +}; + +class SwNotificationReadManager { + private accounts: Accounts = {}; + + public async construct() { + const accounts = await get('accounts'); + if (!accounts) Error('Accounts are not recorded'); + + this.accounts = accounts.reduce((acc, e) => { + acc[e.id] = { + queue: [], + timeout: null + }; + return acc; + }, {} as Accounts); + + return this; + } + + // プッシュ通知の既読をサーバーに送信 + public async read(data: pushNotificationDataMap[K]) { + if (data.type !== 'notification' || !(data.userId in this.accounts)) return; + + const account = this.accounts[data.userId]; + + account.queue.push(data.body.id as string); + + if (account.queue.length >= 20) { + if (account.timeout) clearTimeout(account.timeout); + const notificationIds = account.queue; + account.queue = []; + await api('notifications/read', data.userId, { notificationIds }); + return; + } + + // 最後の呼び出しから200ms待ってまとめて処理する + if (account.timeout) clearTimeout(account.timeout); + account.timeout = setTimeout(() => { + account.timeout = null; + + const notificationIds = account.queue; + account.queue = []; + api('notifications/read', data.userId, { notificationIds }); + }, 200); + } +} + +export const swNotificationRead = (new SwNotificationReadManager()).construct(); diff --git a/packages/sw/src/sw.ts b/packages/sw/src/sw.ts index 24aa07be0..0ba6a6e4a 100644 --- a/packages/sw/src/sw.ts +++ b/packages/sw/src/sw.ts @@ -1,8 +1,11 @@ declare var self: ServiceWorkerGlobalScope; -import { createNotification } from '@/scripts/create-notification'; +import { createEmptyNotification, createNotification } from '@/scripts/create-notification'; import { swLang } from '@/scripts/lang'; +import { swNotificationRead } from '@/scripts/notification-read'; import { pushNotificationDataMap } from '@/types'; +import * as swos from '@/scripts/operations'; +import { acct as getAcct } from '@/filters/user'; self.addEventListener('install', ev => { ev.waitUntil(self.skipWaiting()); @@ -36,33 +39,139 @@ self.addEventListener('push', ev => { const data: pushNotificationDataMap[K] = ev.data?.json(); switch (data.type) { + // case 'driveFileCreated': case 'notification': case 'unreadMessagingMessage': // クライアントがあったらストリームに接続しているということなので通知しない if (clients.length != 0) return; return createNotification(data); + case 'readAllNotifications': + for (const n of await self.registration.getNotifications()) { + if (n?.data?.type === 'notification') n.close(); + } + break; + case 'readAllMessagingMessages': + for (const n of await self.registration.getNotifications()) { + if (n?.data?.type === 'unreadMessagingMessage') n.close(); + } + break; + case 'readNotifications': + for (const n of await self.registration.getNotifications()) { + if (data.body?.notificationIds?.includes(n.data.body.id)) { + n.close(); + } + } + break; + case 'readAllMessagingMessagesOfARoom': + for (const n of await self.registration.getNotifications()) { + if (n.data.type === 'unreadMessagingMessage' + && ('userId' in data.body + ? data.body.userId === n.data.body.userId + : data.body.groupId === n.data.body.groupId) + ) { + n.close(); + } + } + break; } + + return createEmptyNotification(); })); }); self.addEventListener('notificationclick', (ev: ServiceWorkerGlobalScopeEventMap['notificationclick']) => { - ev.notification.close(); - - ev.waitUntil(self.clients.matchAll({ - type: "window" - }).then(clientList => { - for (let i = 0; i < clientList.length; i++) { - const client = clientList[i]; - if (client.url == '/' && 'focus' in client) { - return client.focus(); - } + ev.waitUntil((async () => { + if (_DEV_) { + console.log('notificationclick', ev.action, ev.notification.data); } - if (self.clients.openWindow) { - return self.clients.openWindow('/'); + + const { action, notification } = ev; + const data: pushNotificationDataMap[K] = notification.data; + const { userId: id } = data; + let client: WindowClient | null = null; + + switch (data.type) { + case 'notification': + switch (action) { + case 'follow': + if ('userId' in data.body) await swos.api('following/create', id, { userId: data.body.userId }); + break; + case 'showUser': + if ('user' in data.body) client = await swos.openUser(getAcct(data.body.user), id); + break; + case 'reply': + if ('note' in data.body) client = await swos.openPost({ reply: data.body.note }, id); + break; + case 'renote': + if ('note' in data.body) await swos.api('notes/create', id, { renoteId: data.body.note.id }); + break; + case 'accept': + switch (data.body.type) { + case 'receiveFollowRequest': + await swos.api('following/requests/accept', id, { userId: data.body.userId }); + break; + case 'groupInvited': + await swos.api('users/groups/invitations/accept', id, { invitationId: data.body.invitation.id }); + break; + } + break; + case 'reject': + switch (data.body.type) { + case 'receiveFollowRequest': + await swos.api('following/requests/reject', id, { userId: data.body.userId }); + break; + case 'groupInvited': + await swos.api('users/groups/invitations/reject', id, { invitationId: data.body.invitation.id }); + break; + } + break; + case 'showFollowRequests': + client = await swos.openClient('push', '/my/follow-requests', id); + break; + default: + switch (data.body.type) { + case 'receiveFollowRequest': + client = await swos.openClient('push', '/my/follow-requests', id); + break; + case 'groupInvited': + client = await swos.openClient('push', '/my/groups', id); + break; + case 'reaction': + client = await swos.openNote(data.body.note.id, id); + break; + default: + if ('note' in data.body) { + client = await swos.openNote(data.body.note.id, id); + } else if ('user' in data.body) { + client = await swos.openUser(getAcct(data.body.user), id); + } + break; + } + } + break; + case 'unreadMessagingMessage': + client = await swos.openChat(data.body, id); + break; } - return null; - })); + + if (client) { + client.focus(); + } + if (data.type === 'notification') { + swNotificationRead.then(that => that.read(data)); + } + + notification.close(); + + })()); +}); +self.addEventListener('notificationclose', (ev: ServiceWorkerGlobalScopeEventMap['notificationclose']) => { + const data: pushNotificationDataMap[K] = ev.notification.data; + + if (data.type === 'notification') { + swNotificationRead.then(that => that.read(data)); + } }); self.addEventListener('message', (ev: ServiceWorkerGlobalScopeEventMap['message']) => {