swパッケージに
This commit is contained in:
61
packages/sw/.eslintrc.js
Normal file
61
packages/sw/.eslintrc.js
Normal file
@@ -0,0 +1,61 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
"node": false
|
||||
},
|
||||
parser: "vue-eslint-parser",
|
||||
parserOptions: {
|
||||
"parser": "@typescript-eslint/parser",
|
||||
tsconfigRootDir: __dirname,
|
||||
//project: ['./tsconfig.json'],
|
||||
},
|
||||
extends: [
|
||||
//"../shared/.eslintrc.js",
|
||||
"plugin:vue/vue3-recommended"
|
||||
],
|
||||
rules: {
|
||||
"vue/attributes-order": ["error", {
|
||||
"alphabetical": false
|
||||
}],
|
||||
"vue/no-use-v-if-with-v-for": ["error", {
|
||||
"allowUsingIterationVar": false
|
||||
}],
|
||||
"vue/no-ref-as-operand": "error",
|
||||
"vue/no-multi-spaces": ["error", {
|
||||
"ignoreProperties": false
|
||||
}],
|
||||
"vue/no-v-html": "error",
|
||||
"vue/order-in-components": "error",
|
||||
"vue/html-indent": ["warn", "tab", {
|
||||
"attribute": 1,
|
||||
"baseIndent": 0,
|
||||
"closeBracket": 0,
|
||||
"alignAttributesVertically": true,
|
||||
"ignores": []
|
||||
}],
|
||||
"vue/html-closing-bracket-spacing": ["warn", {
|
||||
"startTag": "never",
|
||||
"endTag": "never",
|
||||
"selfClosingTag": "never"
|
||||
}],
|
||||
"vue/multi-word-component-names": "warn",
|
||||
"vue/require-v-for-key": "warn",
|
||||
"vue/no-unused-components": "warn",
|
||||
"vue/valid-v-for": "warn",
|
||||
"vue/return-in-computed-property": "warn",
|
||||
"vue/max-attributes-per-line": "off",
|
||||
"vue/html-self-closing": "off",
|
||||
"vue/singleline-html-element-content-newline": "off",
|
||||
},
|
||||
globals: {
|
||||
"require": false,
|
||||
"_DEV_": false,
|
||||
"_LANGS_": false,
|
||||
"_VERSION_": false,
|
||||
"_ENV_": false,
|
||||
"_PERF_PREFIX_": false,
|
||||
"_DATA_TRANSFER_DRIVE_FILE_": false,
|
||||
"_DATA_TRANSFER_DRIVE_FOLDER_": false,
|
||||
"_DATA_TRANSFER_DECK_COLUMN_": false
|
||||
}
|
||||
}
|
2
packages/sw/.npmrc
Normal file
2
packages/sw/.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
save-exact = true
|
||||
package-lock = false
|
1
packages/sw/.yarnrc
Normal file
1
packages/sw/.yarnrc
Normal file
@@ -0,0 +1 @@
|
||||
network-timeout 600000
|
14
packages/sw/package.json
Normal file
14
packages/sw/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"watch": "webpack --watch",
|
||||
"build": "webpack",
|
||||
"lint": "eslint --quiet src/**/*.{ts}"
|
||||
},
|
||||
"resolutions": {},
|
||||
"dependencies": {
|
||||
"idb-keyval": "6.0.3",
|
||||
"misskey-js": "0.0.10"
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
231
packages/sw/src/create-notification.ts
Normal file
231
packages/sw/src/create-notification.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* Notification manager for SW
|
||||
*/
|
||||
declare var self: ServiceWorkerGlobalScope;
|
||||
|
||||
import { swLang } from '@/lang';
|
||||
import { cli } from '@/operations';
|
||||
import { pushNotificationDataMap } from '@/types';
|
||||
import { getNoteSummary } from '@/scripts/get-note-summary';
|
||||
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: getNoteSummary(data.body.note, i18n.locale),
|
||||
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: getNoteSummary(data.body.note, i18n.locale),
|
||||
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: getNoteSummary(data.body.note.renote, i18n.locale),
|
||||
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: getNoteSummary(data.body.note, i18n.locale),
|
||||
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: getNoteSummary(data.body.note, i18n.locale),
|
||||
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: getNoteSummary(data.body.note, i18n.locale),
|
||||
icon: data.body.user.avatarUrl,
|
||||
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() {
|
||||
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',
|
||||
}
|
||||
);
|
||||
|
||||
return new Promise<void>(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();
|
||||
}
|
||||
res();
|
||||
}, 1000);
|
||||
});
|
||||
}
|
14
packages/sw/src/filters/user.ts
Normal file
14
packages/sw/src/filters/user.ts
Normal 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}` : '')}`;
|
||||
};
|
47
packages/sw/src/lang.ts
Normal file
47
packages/sw/src/lang.ts
Normal 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();
|
50
packages/sw/src/notification-read.ts
Normal file
50
packages/sw/src/notification-read.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
declare var self: ServiceWorkerGlobalScope;
|
||||
|
||||
import { get } from 'idb-keyval';
|
||||
import { pushNotificationDataMap } from '@/types';
|
||||
import { api } from '@/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();
|
70
packages/sw/src/operations.ts
Normal file
70
packages/sw/src/operations.ts
Normal 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;
|
||||
}
|
7
packages/sw/src/scripts/get-account-from-id.ts
Normal file
7
packages/sw/src/scripts/get-account-from-id.ts
Normal 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);
|
||||
}
|
55
packages/sw/src/scripts/get-note-summary.ts
Normal file
55
packages/sw/src/scripts/get-note-summary.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import * as misskey from 'misskey-js';
|
||||
import { I18n } from '@/scripts/i18n';
|
||||
|
||||
/**
|
||||
* 投稿を表す文字列を取得します。
|
||||
* @param {*} note (packされた)投稿
|
||||
*/
|
||||
export const getNoteSummary = (note: misskey.entities.Note, i18n: I18n<any>): string => {
|
||||
if (note.deletedAt) {
|
||||
return `(${i18n.locale.deletedNote})`;
|
||||
}
|
||||
|
||||
if (note.isHidden) {
|
||||
return `(${i18n.locale.invisibleNote})`;
|
||||
}
|
||||
|
||||
let summary = '';
|
||||
|
||||
// 本文
|
||||
if (note.cw != null) {
|
||||
summary += note.cw;
|
||||
} else {
|
||||
summary += note.text ? note.text : '';
|
||||
}
|
||||
|
||||
// ファイルが添付されているとき
|
||||
if ((note.files || []).length != 0) {
|
||||
summary += ` (${i18n.t('withNFiles', { n: note.files.length })})`;
|
||||
}
|
||||
|
||||
// 投票が添付されているとき
|
||||
if (note.poll) {
|
||||
summary += ` (${i18n.locale.poll})`;
|
||||
}
|
||||
|
||||
// 返信のとき
|
||||
if (note.replyId) {
|
||||
if (note.reply) {
|
||||
summary += `\n\nRE: ${getNoteSummary(note.reply, i18n)}`;
|
||||
} else {
|
||||
summary += '\n\nRE: ...';
|
||||
}
|
||||
}
|
||||
|
||||
// Renoteのとき
|
||||
if (note.renoteId) {
|
||||
if (note.renote) {
|
||||
summary += `\n\nRN: ${getNoteSummary(note.renote, i18n)}`;
|
||||
} else {
|
||||
summary += '\n\nRN: ...';
|
||||
}
|
||||
}
|
||||
|
||||
return summary.trim();
|
||||
};
|
3
packages/sw/src/scripts/get-user-name.ts
Normal file
3
packages/sw/src/scripts/get-user-name.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function(user: { name?: string | null, username: string }): string {
|
||||
return user.name || user.username;
|
||||
}
|
33
packages/sw/src/scripts/i18n.ts
Normal file
33
packages/sw/src/scripts/i18n.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export class I18n<T extends Record<string, any>> {
|
||||
public locale: T;
|
||||
|
||||
constructor(locale: T) {
|
||||
this.locale = locale;
|
||||
|
||||
//#region BIND
|
||||
this.t = this.t.bind(this);
|
||||
//#endregion
|
||||
}
|
||||
|
||||
// string にしているのは、ドット区切りでのパス指定を許可するため
|
||||
// なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも
|
||||
public t(key: string, args?: Record<string, any>): string {
|
||||
try {
|
||||
let str = key.split('.').reduce((o, i) => o[i], this.locale as T | any | string);
|
||||
|
||||
if (typeof str !== 'string') {
|
||||
return key;
|
||||
}
|
||||
|
||||
if (args) {
|
||||
for (const [k, v] of Object.entries(args)) {
|
||||
str = str.replace(`{${k}}`, v);
|
||||
}
|
||||
}
|
||||
return str;
|
||||
} catch (e) {
|
||||
console.warn(`missing localization '${key}'`);
|
||||
return key;
|
||||
}
|
||||
}
|
||||
}
|
11
packages/sw/src/scripts/login-id.ts
Normal file
11
packages/sw/src/scripts/login-id.ts
Normal 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();
|
||||
}
|
216
packages/sw/src/sw.ts
Normal file
216
packages/sw/src/sw.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Service Worker
|
||||
*/
|
||||
declare var self: ServiceWorkerGlobalScope;
|
||||
|
||||
import { createEmptyNotification, createNotification } from '@/create-notification';
|
||||
import { swLang } from '@/lang';
|
||||
import { swNotificationRead } from '@/notification-read';
|
||||
import { pushNotificationDataMap } from '@/types';
|
||||
import * as swos from '@/operations';
|
||||
import { acct as getAcct } from '@/filters/user';
|
||||
|
||||
//#region Lifecycle: Install
|
||||
self.addEventListener('install', ev => {
|
||||
ev.waitUntil(self.skipWaiting());
|
||||
});
|
||||
//#endregion
|
||||
|
||||
//#region Lifecycle: Activate
|
||||
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())
|
||||
);
|
||||
});
|
||||
//#endregion
|
||||
|
||||
//#region When: Fetching
|
||||
self.addEventListener('fetch', ev => {
|
||||
ev.respondWith(
|
||||
fetch(ev.request)
|
||||
.catch(() => new Response(`Offline. Service Worker @${_VERSION_}`, { status: 200 }))
|
||||
);
|
||||
});
|
||||
//#endregion
|
||||
|
||||
//#region When: Caught Notification
|
||||
self.addEventListener('push', ev => {
|
||||
// クライアント取得
|
||||
ev.waitUntil(self.clients.matchAll({
|
||||
includeUncontrolled: true,
|
||||
type: 'window'
|
||||
}).then(async <K extends keyof pushNotificationDataMap>(clients: readonly WindowClient[]) => {
|
||||
// // クライアントがあったらストリームに接続しているということなので通知しない
|
||||
// if (clients.length != 0) return;
|
||||
|
||||
const data: pushNotificationDataMap[K] = ev.data?.json();
|
||||
|
||||
switch (data.type) {
|
||||
// case 'driveFileCreated':
|
||||
case 'notification':
|
||||
case 'unreadMessagingMessage':
|
||||
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;
|
||||
}
|
||||
|
||||
createEmptyNotification();
|
||||
}));
|
||||
});
|
||||
//#endregion
|
||||
|
||||
//#region Notification
|
||||
self.addEventListener('notificationclick', <K extends keyof pushNotificationDataMap>(ev: NotificationEvent) => {
|
||||
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: NotificationEvent) => {
|
||||
const data: pushNotificationDataMap[K] = ev.notification.data;
|
||||
|
||||
if (data.type === 'notification') {
|
||||
swNotificationRead.then(that => that.read(data));
|
||||
}
|
||||
});
|
||||
//#endregion
|
||||
|
||||
//#region When: Caught a message from the client
|
||||
self.addEventListener('message', async ev => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
//#endregion
|
31
packages/sw/src/types.ts
Normal file
31
packages/sw/src/types.ts
Normal 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>;
|
||||
};
|
39
packages/sw/tsconfig.json
Normal file
39
packages/sw/tsconfig.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"noEmitOnError": false,
|
||||
"noImplicitAny": false,
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedParameters": false,
|
||||
"noUnusedLocals": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"declaration": false,
|
||||
"sourceMap": false,
|
||||
"target": "es2017",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"removeComments": false,
|
||||
"noLib": false,
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"experimentalDecorators": true,
|
||||
"resolveJsonModule": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
},
|
||||
"typeRoots": [
|
||||
"node_modules/@types",
|
||||
"@types",
|
||||
],
|
||||
"lib": [
|
||||
"esnext",
|
||||
"webworker"
|
||||
]
|
||||
},
|
||||
"compileOnSave": false,
|
||||
"include": [
|
||||
"./**/*.ts",
|
||||
"./**/*.vue"
|
||||
]
|
||||
}
|
98
packages/sw/webpack.config.js
Normal file
98
packages/sw/webpack.config.js
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* webpack configuration
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const webpack = require('webpack');
|
||||
const { VueLoaderPlugin } = require('vue-loader');
|
||||
|
||||
class WebpackOnBuildPlugin {
|
||||
constructor(callback) {
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
apply(compiler) {
|
||||
compiler.hooks.done.tap('WebpackOnBuildPlugin', this.callback);
|
||||
}
|
||||
}
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
const locales = require('../../locales');
|
||||
const meta = require('../../package.json');
|
||||
|
||||
const postcss = {
|
||||
loader: 'postcss-loader',
|
||||
options: {
|
||||
postcssOptions: {
|
||||
plugins: [
|
||||
require('cssnano')({
|
||||
preset: 'default'
|
||||
})
|
||||
]
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
entry: {
|
||||
sw: './src/sw.ts'
|
||||
},
|
||||
module: {
|
||||
rules: [{
|
||||
test: /\.ts$/,
|
||||
exclude: /node_modules/,
|
||||
use: [{
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
happyPackMode: true,
|
||||
transpileOnly: true,
|
||||
configFile: __dirname + '/tsconfig.json',
|
||||
appendTsSuffixTo: [/\.vue$/]
|
||||
}
|
||||
}]
|
||||
}]
|
||||
},
|
||||
plugins: [
|
||||
new webpack.ProgressPlugin({}),
|
||||
new webpack.DefinePlugin({
|
||||
_VERSION_: JSON.stringify(meta.version),
|
||||
_LANGS_: JSON.stringify(Object.entries(locales).map(([k, v]) => [k, v._lang_])),
|
||||
_ENV_: JSON.stringify(process.env.NODE_ENV),
|
||||
_DEV_: process.env.NODE_ENV !== 'production',
|
||||
_PERF_PREFIX_: JSON.stringify('Misskey:'),
|
||||
_DATA_TRANSFER_DRIVE_FILE_: JSON.stringify('mk_drive_file'),
|
||||
_DATA_TRANSFER_DRIVE_FOLDER_: JSON.stringify('mk_drive_folder'),
|
||||
_DATA_TRANSFER_DECK_COLUMN_: JSON.stringify('mk_deck_column'),
|
||||
__VUE_OPTIONS_API__: true,
|
||||
__VUE_PROD_DEVTOOLS__: false,
|
||||
}),
|
||||
new VueLoaderPlugin(),
|
||||
new WebpackOnBuildPlugin(() => {
|
||||
fs.mkdirSync(__dirname + '/../../built', { recursive: true });
|
||||
fs.writeFileSync(__dirname + '/../../built/meta.json', JSON.stringify({ version: meta.version }), 'utf-8');
|
||||
}),
|
||||
],
|
||||
output: {
|
||||
path: __dirname + '/../../built/_sw_dist_',
|
||||
filename: `[name].${meta.version}.js`,
|
||||
publicPath: `/`,
|
||||
pathinfo: false,
|
||||
},
|
||||
resolve: {
|
||||
extensions: [
|
||||
'.js', '.ts', '.json'
|
||||
],
|
||||
alias: {
|
||||
'@': __dirname + '/src/',
|
||||
}
|
||||
},
|
||||
resolveLoader: {
|
||||
modules: ['node_modules']
|
||||
},
|
||||
experiments: {
|
||||
topLevelAwait: true
|
||||
},
|
||||
devtool: false, //'source-map',
|
||||
mode: isProduction ? 'production' : 'development'
|
||||
};
|
39
packages/sw/yarn.lock
Normal file
39
packages/sw/yarn.lock
Normal file
@@ -0,0 +1,39 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
autobind-decorator@^2.4.0:
|
||||
version "2.4.0"
|
||||
resolved "https://registry.yarnpkg.com/autobind-decorator/-/autobind-decorator-2.4.0.tgz#ea9e1c98708cf3b5b356f7cf9f10f265ff18239c"
|
||||
integrity sha512-OGYhWUO72V6DafbF8PM8rm3EPbfuyMZcJhtm5/n26IDwO18pohE4eNazLoCGhPiXOCD0gEGmrbU3849QvM8bbw==
|
||||
|
||||
eventemitter3@^4.0.7:
|
||||
version "4.0.7"
|
||||
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
|
||||
integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==
|
||||
|
||||
idb-keyval@6.0.3:
|
||||
version "6.0.3"
|
||||
resolved "https://registry.yarnpkg.com/idb-keyval/-/idb-keyval-6.0.3.tgz#e47246a15e55d0fff9fa204fd9ca06f90ff30c52"
|
||||
integrity sha512-yh8V7CnE6EQMu9YDwQXhRxwZh4nv+8xm/HV4ZqK4IiYFJBWYGjJuykADJbSP+F/GDXUBwCSSNn/14IpGL81TuA==
|
||||
dependencies:
|
||||
safari-14-idb-fix "^3.0.0"
|
||||
|
||||
misskey-js@0.0.10:
|
||||
version "0.0.10"
|
||||
resolved "https://registry.yarnpkg.com/misskey-js/-/misskey-js-0.0.10.tgz#f305dd37cecfbaeb7a277d5e0c769ca12c6eb9a6"
|
||||
integrity sha512-2rdqFrCOwggMKYitsUPyupesqCNpNooqEHQQRfdjttbhiqLbNFJE1UuWQ04ffmiJ08UJt+1ZN2kVAYNEN3HRsg==
|
||||
dependencies:
|
||||
autobind-decorator "^2.4.0"
|
||||
eventemitter3 "^4.0.7"
|
||||
reconnecting-websocket "^4.4.0"
|
||||
|
||||
reconnecting-websocket@^4.4.0:
|
||||
version "4.4.0"
|
||||
resolved "https://registry.yarnpkg.com/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz#3b0e5b96ef119e78a03135865b8bb0af1b948783"
|
||||
integrity sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng==
|
||||
|
||||
safari-14-idb-fix@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/safari-14-idb-fix/-/safari-14-idb-fix-3.0.0.tgz#450fc049b996ec7f3fd9ca2f89d32e0761583440"
|
||||
integrity sha512-eBNFLob4PMq8JA1dGyFn6G97q3/WzNtFK4RnzT1fnLq+9RyrGknzYiM/9B12MnKAxuj1IXr7UKYtTNtjyKMBog==
|
Reference in New Issue
Block a user