diff --git a/src/client/account.ts b/src/client/account.ts index e6ee8613d2..85fbca2bd7 100644 --- a/src/client/account.ts +++ b/src/client/account.ts @@ -1,3 +1,4 @@ +import { get, set } from 'idb-keyval'; import { reactive } from 'vue'; import { apiUrl } from '@/config'; import { waiting } from '@/os'; @@ -14,22 +15,44 @@ const data = localStorage.getItem('account'); // TODO: 外部からはreadonlyに export const $i = data ? reactive(JSON.parse(data) as Account) : null; -export function signout() { +export async function signout() { localStorage.removeItem('account'); + + //#region Remove account + const accounts = await getAccounts(); + accounts.splice(accounts.findIndex(x => x.id === $i.id), 1) + set('accounts', JSON.stringify(accounts)); + //#endregion + + //#region Remove push notification registration + await navigator.serviceWorker.ready.then(async r => { + const push = await r.pushManager.getSubscription() + if (!push) return; + return fetch(`${apiUrl}/sw/unregister`, { + method: 'POST', + body: JSON.stringify({ + i: $i.token, + endpoint: push.endpoint, + }), + }); + }); + //#endregion + document.cookie = `igi=; path=/`; - location.href = '/'; + + if (accounts.length > 0) login(accounts[0].token); + else location.href = '/'; } -export function getAccounts() { - const accountsData = localStorage.getItem('accounts'); - const accounts: { id: Account['id'], token: Account['token'] }[] = accountsData ? JSON.parse(accountsData) : []; +export async function getAccounts() { + const accounts: { id: Account['id'], token: Account['token'] }[] = (await get('accounts')) || []; return accounts; } -export function addAccount(id: Account['id'], token: Account['token']) { - const accounts = getAccounts(); +export async function addAccount(id: Account['id'], token: Account['token']) { + const accounts = await getAccounts(); if (!accounts.some(x => x.id === id)) { - localStorage.setItem('accounts', JSON.stringify(accounts.concat([{ id, token }]))); + return set('accounts', accounts.concat([{ id, token }])); } } @@ -68,13 +91,15 @@ export function refreshAccount() { fetchAccount($i.token).then(updateAccount); } -export async function login(token: Account['token']) { +export async function login(token: Account['token'], showTimeline?: boolean) { waiting(); if (_DEV_) console.log('logging as token ', token); const me = await fetchAccount(token); localStorage.setItem('account', JSON.stringify(me)); - addAccount(me.id, token); - location.reload(); + await addAccount(me.id, token); + + if (showTimeline) location.href = '/'; + else location.reload(); } // このファイルに書きたくないけどここに書かないと何故かVeturが認識しない diff --git a/src/client/components/sidebar.vue b/src/client/components/sidebar.vue index 251f68527a..f07e742646 100644 --- a/src/client/components/sidebar.vue +++ b/src/client/components/sidebar.vue @@ -128,7 +128,7 @@ export default defineComponent({ }, async openAccountMenu(ev) { - const storedAccounts = getAccounts(); + const storedAccounts = await getAccounts(); const accounts = (await os.api('users/show', { userIds: storedAccounts.map(x => x.id) })).filter(x => x.id !== this.$i.id); const accountItems = accounts.map(account => ({ @@ -225,7 +225,7 @@ export default defineComponent({ addAcount() { os.popup(import('./signin-dialog.vue'), {}, { - done: res => { + done: async res => { addAccount(res.id, res.i); os.success(); }, @@ -234,15 +234,15 @@ export default defineComponent({ createAccount() { os.popup(import('./signup-dialog.vue'), {}, { - done: res => { - addAccount(res.id, res.i); + done: async res => { + await addAccount(res.id, res.i); this.switchAccountWithToken(res.i); }, }, 'closed'); }, - switchAccount(account: any) { - const storedAccounts = getAccounts(); + async switchAccount(account: any) { + const storedAccounts = await getAccounts(); const token = storedAccounts.find(x => x.id === account.id).token; this.switchAccountWithToken(token); }, diff --git a/src/client/init.ts b/src/client/init.ts index 870304ddef..f305a89aa5 100644 --- a/src/client/init.ts +++ b/src/client/init.ts @@ -4,6 +4,8 @@ import '@/style.scss'; +import { set } from 'idb-keyval'; + // TODO: そのうち消す if (localStorage.getItem('vuex') != null) { const vuex = JSON.parse(localStorage.getItem('vuex')); @@ -12,7 +14,7 @@ if (localStorage.getItem('vuex') != null) { ...vuex.i, token: localStorage.getItem('i') })); - localStorage.setItem('accounts', JSON.stringify(vuex.device.accounts)); + set('accounts', JSON.parse(JSON.stringify(vuex.device.accounts))); localStorage.setItem('miux:themes', JSON.stringify(vuex.device.themes)); for (const [k, v] of Object.entries(vuex.device.userData)) { @@ -153,7 +155,6 @@ if ($i && $i.token) { try { document.body.innerHTML = '
Please wait...
'; await login(i); - location.reload(); } catch (e) { // Render the error screen // TODO: ちゃんとしたコンポーネントをレンダリングする(v10とかのトラブルシューティングゲーム付きのやつみたいな) diff --git a/src/client/sw/compose-notification.ts b/src/client/sw/compose-notification.ts index e9586dd574..72ad4a8f8a 100644 --- a/src/client/sw/compose-notification.ts +++ b/src/client/sw/compose-notification.ts @@ -6,92 +6,100 @@ declare var self: ServiceWorkerGlobalScope; import { getNoteSummary } from '../../misc/get-note-summary'; import getUserName from '../../misc/get-user-name'; -export default async function(type, data, i18n): Promise<[string, NotificationOptions] | null | undefined> { +export default async function(data, i18n): Promise<[string, NotificationOptions] | null | undefined> { if (!i18n) { console.log('no i18n'); return; } - switch (type) { + switch (data.type) { case 'driveFileCreated': // TODO (Server Side) return [i18n.t('_notification.fileUploaded'), { - body: data.name, - icon: data.url + body: data.body.name, + icon: data.body.url, + data }]; case 'notification': - switch (data.type) { + switch (data.body.type) { case 'mention': - return [i18n.t('_notification.youGotMention', { name: getUserName(data.user) }), { - body: getNoteSummary(data.note, i18n.locale), - icon: data.user.avatarUrl + return [i18n.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 [i18n.t('_notification.youGotReply', { name: getUserName(data.user) }), { - body: getNoteSummary(data.note, i18n.locale), - icon: data.user.avatarUrl + return [i18n.t('_notification.youGotReply', { name: getUserName(data.body.user) }), { + body: getNoteSummary(data.body.note, i18n.locale), + icon: data.body.user.avatarUrl }]; case 'renote': - return [i18n.t('_notification.youRenoted', { name: getUserName(data.user) }), { - body: getNoteSummary(data.note, i18n.locale), - icon: data.user.avatarUrl + return [i18n.t('_notification.youRenoted', { name: getUserName(data.body.user) }), { + body: getNoteSummary(data.body.note, i18n.locale), + icon: data.body.user.avatarUrl }]; case 'quote': - return [i18n.t('_notification.youGotQuote', { name: getUserName(data.user) }), { - body: getNoteSummary(data.note, i18n.locale), - icon: data.user.avatarUrl + return [i18n.t('_notification.youGotQuote', { name: getUserName(data.body.user) }), { + body: getNoteSummary(data.body.note, i18n.locale), + icon: data.body.user.avatarUrl }]; case 'reaction': - return [`${data.reaction} ${getUserName(data.user)}`, { - body: getNoteSummary(data.note, i18n.locale), - icon: data.user.avatarUrl + return [`${data.body.reaction} ${getUserName(data.body.user)}`, { + body: getNoteSummary(data.body.note, i18n.locale), + icon: data.body.user.avatarUrl }]; case 'pollVote': - return [i18n.t('_notification.youGotPoll', { name: getUserName(data.user) }), { - body: getNoteSummary(data.note, i18n.locale), - icon: data.user.avatarUrl + return [i18n.t('_notification.youGotPoll', { name: getUserName(data.body.user) }), { + body: getNoteSummary(data.body.note, i18n.locale), + icon: data.body.user.avatarUrl }]; case 'follow': return [i18n.t('_notification.youWereFollowed'), { - body: getUserName(data.user), - icon: data.user.avatarUrl + body: getUserName(data.body.user), + icon: data.body.user.avatarUrl }]; case 'receiveFollowRequest': return [i18n.t('_notification.youReceivedFollowRequest'), { - body: getUserName(data.user), - icon: data.user.avatarUrl + body: getUserName(data.body.user), + icon: data.body.user.avatarUrl }]; case 'followRequestAccepted': return [i18n.t('_notification.yourFollowRequestAccepted'), { - body: getUserName(data.user), - icon: data.user.avatarUrl + body: getUserName(data.body.user), + icon: data.body.user.avatarUrl }]; case 'groupInvited': return [i18n.t('_notification.youWereInvitedToGroup'), { - body: data.group.name + body: data.body.group.name }]; default: return null; } case 'unreadMessagingMessage': - if (data.groupId === null) { - return [i18n.t('_notification.youGotMessagingMessageFromUser', { name: getUserName(data.user) }), { - icon: data.user.avatarUrl, - tag: `messaging:user:${data.user.id}` + if (data.body.groupId === null) { + return [i18n.t('_notification.youGotMessagingMessageFromUser', { name: getUserName(data.body.user) }), { + icon: data.body.user.avatarUrl, + tag: `messaging:user:${data.body.user.id}` }]; } - return [i18n.t('_notification.youGotMessagingMessageFromGroup', { name: data.group.name }), { - icon: data.user.avatarUrl, - tag: `messaging:group:${data.group.id}` + return [i18n.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/sw.ts b/src/client/sw/sw.ts index c92cae1292..d69b7693d5 100644 --- a/src/client/sw/sw.ts +++ b/src/client/sw/sw.ts @@ -74,20 +74,69 @@ self.addEventListener('push', ev => { ev.waitUntil(self.clients.matchAll({ includeUncontrolled: true }).then(async clients => { - // クライアントがあったらストリームに接続しているということなので通知しない - if (clients.length != 0) return; + // // クライアントがあったらストリームに接続しているということなので通知しない + // if (clients.length != 0) return; - const { type, body } = ev.data?.json(); + const data = ev.data?.json(); // localeを読み込めておらずi18nがundefinedだった場合はpushesPoolにためておく - if (!i18n) return pushesPool.push({ type, body }); + if (!i18n) return pushesPool.push(data); - const n = await composeNotification(type, body, i18n); + const n = await composeNotification(data, i18n); if (n) return self.registration.showNotification(...n); })); }); //#endregion +//#region Notification +self.addEventListener('notificationclick', ev => { + const { action, notification } = ev; + const { data } = notification; + const { origin } = location; + + switch (action) { + case 'showUser': + switch (data.body.type) { + case 'reaction': + self.clients.openWindow(`${origin}/users/${data.body.user.id}`); + break; + + default: + if ('note' in data.body) { + self.clients.openWindow(`${origin}/notes/${data.body.note.id}`); + } + } + break; + default: + } + + notification.close(); +}); + +self.addEventListener('notificationclose', async ev => { + self.registration.showNotification('notificationclose'); + const { notification } = ev; + const { data } = notification + + if (data.isNotification) { + const { origin } = location; + + const accounts = await get('accounts'); + const account = accounts.find(i => i.id === data.userId); + + if (!account) return; + + fetch(`${origin}/api/notifications/read`, { + method: 'POST', + body: JSON.stringify({ + i: account.token, + notificationIds: [data.data.id] + }) + }); + } +}); +//#endregion + //#region When: Caught a message from the client self.addEventListener('message', ev => { switch(ev.data) { @@ -131,8 +180,8 @@ async function fetchLocale() { //#endregion //#region i18nをきちんと読み込んだ後にやりたい処理 - for (const { type, body } of pushesPool) { - const n = await composeNotification(type, body, i18n); + for (const data of pushesPool) { + const n = await composeNotification(data, i18n); if (n) self.registration.showNotification(...n); } pushesPool = []; diff --git a/src/server/api/endpoints/notifications/read.ts b/src/server/api/endpoints/notifications/read.ts new file mode 100644 index 0000000000..d43348b205 --- /dev/null +++ b/src/server/api/endpoints/notifications/read.ts @@ -0,0 +1,30 @@ +import $ from 'cafy'; +import { ID } from '../../../../misc/cafy-id'; +import { publishMainStream } from '../../../../services/stream'; +import define from '../../define'; +import { readNotification } from '../../common/read-notification'; + +export const meta = { + desc: { + 'ja-JP': '通知を既読にします。', + 'en-US': 'Mark a notification as read.' + }, + + tags: ['notifications', 'account'], + + requireCredential: true as const, + + params: { + notificationIds: { + validator: $.arr($.type(ID)), + desc: { + 'ja-JP': '対象の通知のIDの配列', + 'en-US': 'Target notification IDs.' + } + } + }, + + kind: 'write:notifications' +}; + +export default define(meta, async (ps, user) => readNotification(user.id, ps.notificationIds)); diff --git a/src/server/api/endpoints/sw/unregister.ts b/src/server/api/endpoints/sw/unregister.ts new file mode 100644 index 0000000000..e086357db8 --- /dev/null +++ b/src/server/api/endpoints/sw/unregister.ts @@ -0,0 +1,37 @@ +import $ from 'cafy'; +import define from '../../define'; +import { SwSubscriptions } from '../../../../models'; + +export const meta = { + tags: ['account'], + + requireCredential: true as const, + + desc: { + 'ja-JP': 'Push通知の登録を削除します。', + 'en-US': 'Remove push noticfication registration' + }, + + params: { + endpoint: { + validator: $.str + }, + + all: { + validator: $.optional.bool, + default: false, + desc: { + 'ja-JP': 'false(デフォルト)は、自分の登録のみが解除されます。trueを指定すると、指定したエンドポイントのすべての登録を解除します。' + } + } + } +}; + +export default define(meta, async (ps, user) => { + await SwSubscriptions.delete(ps.all ? { + endpoint: ps.endpoint, + } : { + userId: user.id, + endpoint: ps.endpoint, + }); +}); diff --git a/src/services/push-notification.ts b/src/services/push-notification.ts index d0a0c04d62..aad9964061 100644 --- a/src/services/push-notification.ts +++ b/src/services/push-notification.ts @@ -33,7 +33,7 @@ export default async function(userId: string, type: notificationType, body: noti }; push.sendNotification(pushSubscription, JSON.stringify({ - type, body + type, body, userId }), { proxy: config.proxy }).catch((err: any) => {