feat: Improve Push Notification (#7667)

* clean up

* ev => data

* refactor

* clean up

* add type

* antenna

* channel

* fix

* add Packed type

* add PackedRef

* fix lint

* add emoji schema

* add reversiGame

* add reversiMatching

* remove signin schema (use Signin entity)

* add schemas refs, fix Packed type

* wip PackedHoge => Packed<'Hoge'>

* add Packed type

* note-reaction

* user

* user-group

* user-list

* note

* app, messaging-message

* notification

* drive-file

* drive-folder

* following

* muting

* blocking

* hashtag

* page

* app (with modifying schema)

* import user?

* channel

* antenna

* clip

* gallery-post

* emoji

* Packed

* reversi-matching

* update stream.ts

* https://github.com/misskey-dev/misskey/pull/7769#issuecomment-917542339

* fix lint

* clean up?

* add app

* fix

* nanka iroiro

* wip

* wip

* fix lint

* fix loginId

* fix

* refactor

* refactor

* remove follow action

* clean up

* Revert "remove follow action"

This reverts commit defbb41648.

* Revert "clean up"

This reverts commit f94919cb9c.

* remove fetch specification

* renoteの条件追加

* apiFetch => cli

* bypass fetch?

* fix

* refactor: use path alias

* temp: add submodule

* remove submodule

* enhane: unison-reloadに指定したパスに移動できるように

* null

* null

* feat: ログインするアカウントのIDをクエリ文字列で指定する機能

* null

* await?

* rename

* rename

* Update read.ts

* merge

* get-note-summary

* fix

* swパッケージに

* add missing packages

* fix getNoteSummary

* add webpack-cli

* ✌️

* remove plugins

* sw-inject分離したがテストしてない

* fix notification.vue

* remove a blank line

* disconnect intersection observer

* disconnect2

* fix notification.vue

* remove a blank line

* disconnect intersection observer

* disconnect2

* fix

* ✌️

* clean up config

* typesを戻した

* Update packages/client/src/components/notification.vue

Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>

* disconnect

* oops

* Failed to load the script unexpectedly回避
sw.jsとlib.tsを分離してみた

* truncate notification

* Update packages/client/src/ui/_common_/common.vue

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>

* clean up

* clean up

* キャッシュ対策

* Truncate push notification message

* クライアントがあったらストリームに接続しているということなので通知しない判定の位置を修正

* components/drive-file-thumbnail.vue

* components/drive-select-dialog.vue

* components/drive-window.vue

* merge

* fix

* Service Workerのビルドにesbuildを使うようにする

* return createEmptyNotification()

* fix

* i18n.ts

* update

* ✌️

* remove ts-loader

* fix

* fix

* enhance: Service Workerを常に登録するように

* pollEnded

* URLをsw.jsに戻す

* clean up

Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
This commit is contained in:
tamaina
2022-04-30 21:52:07 +09:00
committed by GitHub
parent 1c6d5ddf81
commit 766559c6e9
47 changed files with 1834 additions and 298 deletions

View File

@@ -0,0 +1,14 @@
import * as misskey from 'misskey-js';
import * as Acct from 'misskey-js/built/acct';
export const acct = (user: misskey.Acct) => {
return Acct.toString(user);
};
export const userName = (user: misskey.entities.User) => {
return user.name || user.username;
};
export const userPage = (user: misskey.Acct, path?, absolute = false) => {
return `${absolute ? origin : ''}/@${acct(user)}${(path ? `/${path}` : '')}`;
};

View File

@@ -0,0 +1,237 @@
/*
* Notification manager for SW
*/
declare var self: ServiceWorkerGlobalScope;
import { swLang } from '@/scripts/lang';
import { cli } from '@/scripts/operations';
import { pushNotificationDataMap } from '@/types';
import getUserName from '@/scripts/get-user-name';
import { I18n } from '@/scripts/i18n';
import { getAccountFromId } from '@/scripts/get-account-from-id';
export async function createNotification<K extends keyof pushNotificationDataMap>(data: pushNotificationDataMap[K]) {
const n = await composeNotification(data);
if (n) {
return self.registration.showNotification(...n);
} else {
console.error('Could not compose notification', data);
return createEmptyNotification();
}
}
async function composeNotification<K extends keyof pushNotificationDataMap>(data: pushNotificationDataMap[K]): Promise<[string, NotificationOptions] | null> {
if (!swLang.i18n) swLang.fetchLocale();
const i18n = await swLang.i18n as I18n<any>;
const { t } = i18n;
switch (data.type) {
/*
case 'driveFileCreated': // TODO (Server Side)
return [t('_notification.fileUploaded'), {
body: body.name,
icon: body.url,
data
}];
*/
case 'notification':
switch (data.body.type) {
case 'follow':
// 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,
data,
actions: userDetail.isFollowing ? [] : [
{
action: 'follow',
title: t('_notification._actions.followBack')
}
],
}];
case 'mention':
return [t('_notification.youGotMention', { name: getUserName(data.body.user) }), {
body: data.body.note.text || '',
icon: data.body.user.avatarUrl,
data,
actions: [
{
action: 'reply',
title: t('_notification._actions.reply')
}
],
}];
case 'reply':
return [t('_notification.youGotReply', { name: getUserName(data.body.user) }), {
body: data.body.note.text || '',
icon: data.body.user.avatarUrl,
data,
actions: [
{
action: 'reply',
title: t('_notification._actions.reply')
}
],
}];
case 'renote':
return [t('_notification.youRenoted', { name: getUserName(data.body.user) }), {
body: data.body.note.text || '',
icon: data.body.user.avatarUrl,
data,
actions: [
{
action: 'showUser',
title: getUserName(data.body.user)
}
],
}];
case 'quote':
return [t('_notification.youGotQuote', { name: getUserName(data.body.user) }), {
body: data.body.note.text || '',
icon: data.body.user.avatarUrl,
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':
return [`${data.body.reaction} ${getUserName(data.body.user)}`, {
body: data.body.note.text || '',
icon: data.body.user.avatarUrl,
data,
actions: [
{
action: 'showUser',
title: getUserName(data.body.user)
}
],
}];
case 'pollVote':
return [t('_notification.youGotPoll', { name: getUserName(data.body.user) }), {
body: data.body.note.text || '',
icon: data.body.user.avatarUrl,
data,
}];
case 'pollEnded':
return [t('_notification.pollEnded'), {
body: data.body.note.text || '',
data,
}];
case 'receiveFollowRequest':
return [t('_notification.youReceivedFollowRequest'), {
body: getUserName(data.body.user),
icon: data.body.user.avatarUrl,
data,
actions: [
{
action: 'accept',
title: t('accept')
},
{
action: 'reject',
title: t('reject')
}
],
}];
case 'followRequestAccepted':
return [t('_notification.yourFollowRequestAccepted'), {
body: getUserName(data.body.user),
icon: data.body.user.avatarUrl,
data,
}];
case 'groupInvited':
return [t('_notification.youWereInvitedToGroup', { userName: getUserName(data.body.user) }), {
body: data.body.invitation.group.name,
data,
actions: [
{
action: 'accept',
title: t('accept')
},
{
action: 'reject',
title: t('reject')
}
],
}];
case 'app':
return [data.body.header || data.body.body, {
body: data.body.header && data.body.body,
icon: data.body.icon,
data
}];
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.userId}`,
data,
renotify: true,
}];
}
return [t('_notification.youGotMessagingMessageFromGroup', { name: data.body.group.name }), {
icon: data.body.user.avatarUrl,
tag: `messaging:group:${data.body.groupId}`,
data,
renotify: true,
}];
default:
return null;
}
}
export async function createEmptyNotification() {
return new Promise<void>(async res => {
if (!swLang.i18n) swLang.fetchLocale();
const i18n = await swLang.i18n as I18n<any>;
const { t } = i18n;
await self.registration.showNotification(
t('_notification.emptyPushNotificationMessage'),
{
silent: true,
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);
});
}

View File

@@ -0,0 +1,7 @@
import { get } from 'idb-keyval';
export async function getAccountFromId(id: string) {
const accounts = await get('accounts') as { token: string; id: string; }[];
if (!accounts) console.log('Accounts are not recorded');
return accounts.find(e => e.id === id);
}

View File

@@ -0,0 +1,3 @@
export default function(user: { name?: string | null, username: string }): string {
return user.name || user.username;
}

View File

@@ -0,0 +1,29 @@
export class I18n<T extends Record<string, any>> {
public ts: T;
constructor(locale: T) {
this.ts = locale;
//#region BIND
this.t = this.t.bind(this);
//#endregion
}
// string にしているのは、ドット区切りでのパス指定を許可するため
// なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも
public t(key: string, args?: Record<string, string>): string {
try {
let str = key.split('.').reduce((o, i) => o[i], this.ts) as unknown as string;
if (args) {
for (const [k, v] of Object.entries(args)) {
str = str.replace(`{${k}}`, v);
}
}
return str;
} catch (err) {
console.warn(`missing localization '${key}'`);
return key;
}
}
}

View File

@@ -0,0 +1,47 @@
/*
* Language manager for SW
*/
declare var self: ServiceWorkerGlobalScope;
import { get, set } from 'idb-keyval';
import { I18n } from '@/scripts/i18n';
class SwLang {
public cacheName = `mk-cache-${_VERSION_}`;
public lang: Promise<string> = get('lang').then(async prelang => {
if (!prelang) return 'en-US';
return prelang;
});
public setLang(newLang: string) {
this.lang = Promise.resolve(newLang);
set('lang', newLang);
return this.fetchLocale();
}
public i18n: Promise<I18n<any>> | null = null;
public fetchLocale() {
return this.i18n = this._fetch();
}
private async _fetch() {
// Service Workerは何度も起動しそのたびにlocaleを読み込むので、CacheStorageを使う
const localeUrl = `/assets/locales/${await this.lang}.${_VERSION_}.json`;
let localeRes = await caches.match(localeUrl);
// _DEV_がtrueの場合は常に最新化
if (!localeRes || _DEV_) {
localeRes = await fetch(localeUrl);
const clone = localeRes?.clone();
if (!clone?.clone().ok) Error('locale fetching error');
caches.open(this.cacheName).then(cache => cache.put(localeUrl, clone));
}
return new I18n(await localeRes.json());
}
}
export const swLang = new SwLang();

View File

@@ -0,0 +1,11 @@
export function getUrlWithLoginId(url: string, loginId: string) {
const u = new URL(url, origin);
u.searchParams.append('loginId', loginId);
return u.toString();
}
export function getUrlWithoutLoginId(url: string) {
const u = new URL(url);
u.searchParams.delete('loginId');
return u.toString();
}

View File

@@ -0,0 +1,50 @@
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<K extends keyof pushNotificationDataMap>(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);
// 最後の呼び出しから200ms待ってまとめて処理する
if (account.timeout) clearTimeout(account.timeout);
account.timeout = setTimeout(() => {
account.timeout = null;
api('notifications/read', data.userId, { notificationIds: account.queue });
}, 200);
}
}
export const swNotificationRead = (new SwNotificationReadManager()).construct();

View File

@@ -0,0 +1,70 @@
/*
* Operations
* 各種操作
*/
declare var self: ServiceWorkerGlobalScope;
import * as Misskey from 'misskey-js';
import { SwMessage, swMessageOrderType } from '@/types';
import { acct as getAcct } from '@/filters/user';
import { getAccountFromId } from '@/scripts/get-account-from-id';
import { getUrlWithLoginId } from '@/scripts/login-id';
export const cli = new Misskey.api.APIClient({ origin, fetch: (...args) => fetch(...args) });
export async function api<E extends keyof Misskey.Endpoints>(endpoint: E, userId: string, options?: Misskey.Endpoints[E]['req']) {
const account = await getAccountFromId(userId);
if (!account) return;
return cli.request(endpoint, options, account.token);
}
// rendered acctからユーザーを開く
export function openUser(acct: string, loginId: string) {
return openClient('push', `/@${acct}`, loginId, { acct });
}
// noteIdからートを開く
export function openNote(noteId: string, loginId: string) {
return openClient('push', `/notes/${noteId}`, loginId, { noteId });
}
export async function openChat(body: any, loginId: string) {
if (body.groupId === null) {
return openClient('push', `/my/messaging/${getAcct(body.user)}`, loginId, { body });
} else {
return openClient('push', `/my/messaging/group/${body.groupId}`, loginId, { body });
}
}
// post-formのオプションから投稿フォームを開く
export async function openPost(options: any, loginId: string) {
// クエリを作成しておく
let url = `/share?`;
if (options.initialText) url += `text=${options.initialText}&`;
if (options.reply) url += `replyId=${options.reply.id}&`;
if (options.renote) url += `renoteId=${options.renote.id}&`;
return openClient('post', url, loginId, { options });
}
export async function openClient(order: swMessageOrderType, url: string, loginId: string, query: any = {}) {
const client = await findClient();
if (client) {
client.postMessage({ type: 'order', ...query, order, loginId, url } as SwMessage);
return client;
}
return self.clients.openWindow(getUrlWithLoginId(url, loginId));
}
export async function findClient() {
const clients = await self.clients.matchAll({
type: 'window'
});
for (const c of clients) {
if (c.url.indexOf('?zen') < 0) return c;
}
return null;
}

200
packages/sw/src/sw.ts Normal file
View File

@@ -0,0 +1,200 @@
declare var self: ServiceWorkerGlobalScope;
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());
});
self.addEventListener('activate', ev => {
ev.waitUntil(
caches.keys()
.then(cacheNames => Promise.all(
cacheNames
.filter((v) => v !== swLang.cacheName)
.map(name => caches.delete(name))
))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', ev => {
ev.respondWith(
fetch(ev.request)
.catch(() => new Response(`Offline. Service Worker @${_VERSION_}`, { status: 200 }))
);
});
self.addEventListener('push', ev => {
// クライアント取得
ev.waitUntil(self.clients.matchAll({
includeUncontrolled: true,
type: 'window'
}).then(async <K extends keyof pushNotificationDataMap>(clients: readonly WindowClient[]) => {
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', <K extends keyof pushNotificationDataMap>(ev: ServiceWorkerGlobalScopeEventMap['notificationclick']) => {
ev.waitUntil((async () => {
if (_DEV_) {
console.log('notificationclick', ev.action, ev.notification.data);
}
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;
}
if (client) {
client.focus();
}
if (data.type === 'notification') {
swNotificationRead.then(that => that.read(data));
}
notification.close();
})());
});
self.addEventListener('notificationclose', <K extends keyof pushNotificationDataMap>(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']) => {
ev.waitUntil((async () => {
switch (ev.data) {
case 'clear':
// Cache Storage全削除
await caches.keys()
.then(cacheNames => Promise.all(
cacheNames.map(name => caches.delete(name))
));
return; // TODO
}
if (typeof ev.data === 'object') {
// E.g. '[object Array]' → 'array'
const otype = Object.prototype.toString.call(ev.data).slice(8, -1).toLowerCase();
if (otype === 'object') {
if (ev.data.msg === 'initialize') {
swLang.setLang(ev.data.lang);
}
}
}
})());
});

31
packages/sw/src/types.ts Normal file
View File

@@ -0,0 +1,31 @@
import * as Misskey from 'misskey-js';
export type swMessageOrderType = 'post' | 'push';
export type SwMessage = {
type: 'order';
order: swMessageOrderType;
loginId: string;
url: string;
[x: string]: any;
};
// Defined also @/services/push-notification.ts#L7-L14
type pushNotificationDataSourceMap = {
notification: Misskey.entities.Notification;
unreadMessagingMessage: Misskey.entities.MessagingMessage;
readNotifications: { notificationIds: string[] };
readAllNotifications: undefined;
readAllMessagingMessages: undefined;
readAllMessagingMessagesOfARoom: { userId: string } | { groupId: string };
};
export type pushNotificationData<K extends keyof pushNotificationDataSourceMap> = {
type: K;
body: pushNotificationDataSourceMap[K];
userId: string;
};
export type pushNotificationDataMap = {
[K in keyof pushNotificationDataSourceMap]: pushNotificationData<K>;
};