Firefish v1.0
This commit is contained in:
commit
fe91a9fd47
2279 changed files with 314731 additions and 0 deletions
24
packages/sw/.swcrc
Normal file
24
packages/sw/.swcrc
Normal file
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/swcrc",
|
||||
"jsc": {
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"dynamicImport": true,
|
||||
"decorators": true
|
||||
},
|
||||
"transform": {
|
||||
"decoratorMetadata": true
|
||||
},
|
||||
"experimental": {
|
||||
"keepImportAssertions": true
|
||||
},
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"target": "es2022"
|
||||
},
|
||||
"minify": false
|
||||
}
|
20
packages/sw/package.json
Normal file
20
packages/sw/package.json
Normal file
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "sw",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "webpack",
|
||||
"watch": "pnpm swc src -d built -D -w",
|
||||
"lint": "pnpm rome check **/*.ts --apply",
|
||||
"format": "pnpm rome format * --write"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@swc/cli": "^0.1.62",
|
||||
"@swc/core": "^1.3.62",
|
||||
"@swc/core-android-arm64": "1.3.11",
|
||||
"firefish-js": "workspace:*",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"swc-loader": "^0.2.3",
|
||||
"webpack": "^5.85.1",
|
||||
"webpack-cli": "^5.1.3"
|
||||
}
|
||||
}
|
8
packages/sw/src/@types/global.d.ts
vendored
Normal file
8
packages/sw/src/@types/global.d.ts
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type FIXME = any;
|
||||
|
||||
declare const _LANGS_: string[][];
|
||||
declare const _VERSION_: string;
|
||||
declare const _ENV_: string;
|
||||
declare const _DEV_: boolean;
|
||||
declare const _PERF_PREFIX_: string;
|
365
packages/sw/src/scripts/create-notification.ts
Normal file
365
packages/sw/src/scripts/create-notification.ts
Normal file
|
@ -0,0 +1,365 @@
|
|||
/*
|
||||
* Notification manager for SW
|
||||
*/
|
||||
import type { BadgeNames, PushNotificationDataMap } from "@/types";
|
||||
import { char2fileName } from "@/scripts/twemoji-base";
|
||||
import { cli } from "@/scripts/operations";
|
||||
import { getAccountFromId } from "@/scripts/get-account-from-id";
|
||||
import { swLang } from "@/scripts/lang";
|
||||
import { getUserName } from "@/scripts/get-user-name";
|
||||
|
||||
const closeNotificationsByTags = async (tags: string[]): Promise<void> => {
|
||||
for (const n of (
|
||||
await Promise.all(
|
||||
tags.map((tag) => globalThis.registration.getNotifications({ tag })),
|
||||
)
|
||||
).flat()) {
|
||||
n.close();
|
||||
}
|
||||
};
|
||||
|
||||
const iconUrl = (name: BadgeNames): string =>
|
||||
`/static-assets/tabler-badges/${name}.png`;
|
||||
/* How to add a new badge:
|
||||
* 1. Find the icon and download png from https://tabler-icons.io/
|
||||
* 2. vips resize ~/Downloads/icon-name.png vipswork.png 0.4; vips scRGB2BW vipswork.png ~/icon-name.png"[compression=9,strip]"; rm vipswork.png;
|
||||
* 3. mv ~/icon-name.png ~/misskey/packages/backend/assets/tabler-badges/
|
||||
* 4. Add 'icon-name' to BadgeNames
|
||||
* 5. Add `badge: iconUrl('icon-name'),`
|
||||
*/
|
||||
|
||||
export async function createNotification<
|
||||
K extends keyof PushNotificationDataMap,
|
||||
>(data: PushNotificationDataMap[K]): Promise<void> {
|
||||
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(
|
||||
data: PushNotificationDataMap[keyof PushNotificationDataMap],
|
||||
): Promise<[string, NotificationOptions] | null> {
|
||||
const i18n = await (swLang.i18n ?? swLang.fetchLocale());
|
||||
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,
|
||||
badge: iconUrl("user-plus"),
|
||||
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,
|
||||
badge: iconUrl("at"),
|
||||
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,
|
||||
badge: iconUrl("reply"),
|
||||
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,
|
||||
badge: iconUrl("retweet"),
|
||||
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,
|
||||
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": {
|
||||
let reaction = data.body.reaction;
|
||||
let badge: string | undefined;
|
||||
|
||||
if (reaction.startsWith(":")) {
|
||||
// カスタム絵文字の場合
|
||||
const name = reaction.substring(1, reaction.length - 1);
|
||||
const badgeUrl = new URL(`/emoji/${name}.webp`, origin);
|
||||
badgeUrl.searchParams.set("badge", "1");
|
||||
badge = badgeUrl.href;
|
||||
reaction = name.split("@")[0];
|
||||
} else {
|
||||
// Unicode絵文字の場合
|
||||
badge = `/twemoji-badge/${char2fileName(reaction)}.png`;
|
||||
}
|
||||
|
||||
if (
|
||||
await fetch(badge)
|
||||
.then((res) => res.status !== 200)
|
||||
.catch(() => true)
|
||||
) {
|
||||
badge = iconUrl("plus");
|
||||
}
|
||||
|
||||
return [
|
||||
`${reaction} ${getUserName(data.body.user)}`,
|
||||
{
|
||||
body: data.body.note.text || "",
|
||||
icon: data.body.user.avatarUrl,
|
||||
badge,
|
||||
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,
|
||||
badge: iconUrl("poll-h"),
|
||||
data,
|
||||
},
|
||||
];
|
||||
|
||||
case "pollEnded":
|
||||
return [
|
||||
t("_notification.pollEnded"),
|
||||
{
|
||||
body: data.body.note.text || "",
|
||||
badge: iconUrl("clipboard-check-solid"),
|
||||
data,
|
||||
},
|
||||
];
|
||||
|
||||
case "receiveFollowRequest":
|
||||
return [
|
||||
t("_notification.youReceivedFollowRequest"),
|
||||
{
|
||||
body: getUserName(data.body.user),
|
||||
icon: data.body.user.avatarUrl,
|
||||
badge: iconUrl("clock"),
|
||||
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,
|
||||
badge: iconUrl("check"),
|
||||
data,
|
||||
},
|
||||
];
|
||||
|
||||
case "groupInvited":
|
||||
return [
|
||||
t("_notification.youWereInvitedToGroup", {
|
||||
userName: getUserName(data.body.user),
|
||||
}),
|
||||
{
|
||||
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":
|
||||
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,
|
||||
badge: iconUrl("comments"),
|
||||
tag: `messaging:user:${data.body.userId}`,
|
||||
data,
|
||||
renotify: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
t("_notification.youGotMessagingMessageFromGroup", {
|
||||
name: data.body.group.name,
|
||||
}),
|
||||
{
|
||||
icon: data.body.user.avatarUrl,
|
||||
badge: iconUrl("comments"),
|
||||
tag: `messaging:group:${data.body.groupId}`,
|
||||
data,
|
||||
renotify: true,
|
||||
},
|
||||
];
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createEmptyNotification(): Promise<void> {
|
||||
return new Promise<void>(async (res) => {
|
||||
const i18n = await (swLang.i18n ?? swLang.fetchLocale());
|
||||
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);
|
||||
});
|
||||
}
|
12
packages/sw/src/scripts/get-account-from-id.ts
Normal file
12
packages/sw/src/scripts/get-account-from-id.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { get } from "idb-keyval";
|
||||
|
||||
export async function getAccountFromId(
|
||||
id: string,
|
||||
): Promise<{ token: string; id: string } | void> {
|
||||
const accounts = await get<{ token: string; id: string }[]>("accounts");
|
||||
if (!accounts) {
|
||||
console.log("Accounts are not recorded");
|
||||
return;
|
||||
}
|
||||
return accounts.find((e) => e.id === id);
|
||||
}
|
6
packages/sw/src/scripts/get-user-name.ts
Normal file
6
packages/sw/src/scripts/get-user-name.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export function getUserName(user: {
|
||||
name?: string | null;
|
||||
username: string;
|
||||
}): string {
|
||||
return user.name === "" ? user.username : user.name ?? user.username;
|
||||
}
|
34
packages/sw/src/scripts/i18n.ts
Normal file
34
packages/sw/src/scripts/i18n.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
export type Locale = { [key: string]: string | Locale };
|
||||
|
||||
export class I18n<T extends Locale = Locale> {
|
||||
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<Locale | Locale[keyof Locale]>((o, i) => o[i], this.ts);
|
||||
if (typeof str !== "string") throw new Error();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
45
packages/sw/src/scripts/lang.ts
Normal file
45
packages/sw/src/scripts/lang.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Language manager for SW
|
||||
*/
|
||||
import { get, set } from "idb-keyval";
|
||||
import { I18n, type Locale } 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): Promise<I18n<Locale>> {
|
||||
this.lang = Promise.resolve(newLang);
|
||||
set("lang", newLang);
|
||||
return this.fetchLocale();
|
||||
}
|
||||
|
||||
public i18n: Promise<I18n> | null = null;
|
||||
|
||||
public fetchLocale(): Promise<I18n<Locale>> {
|
||||
return (this.i18n = this._fetch());
|
||||
}
|
||||
|
||||
private async _fetch(): Promise<I18n<Locale>> {
|
||||
// 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) throw new Error("locale fetching error");
|
||||
|
||||
caches.open(this.cacheName).then((cache) => cache.put(localeUrl, clone));
|
||||
}
|
||||
|
||||
return new I18n<Locale>(await localeRes.json());
|
||||
}
|
||||
}
|
||||
|
||||
export const swLang = new SwLang();
|
5
packages/sw/src/scripts/login-id.ts
Normal file
5
packages/sw/src/scripts/login-id.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export function getUrlWithLoginId(url: string, loginId: string): string {
|
||||
const u = new URL(url, origin);
|
||||
u.searchParams.set("loginId", loginId);
|
||||
return u.toString();
|
||||
}
|
62
packages/sw/src/scripts/notification-read.ts
Normal file
62
packages/sw/src/scripts/notification-read.ts
Normal file
|
@ -0,0 +1,62 @@
|
|||
declare let 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);
|
||||
|
||||
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();
|
120
packages/sw/src/scripts/operations.ts
Normal file
120
packages/sw/src/scripts/operations.ts
Normal file
|
@ -0,0 +1,120 @@
|
|||
/*
|
||||
* Operations
|
||||
* 各種操作
|
||||
*/
|
||||
import * as Misskey from "firefish-js";
|
||||
import type { SwMessage, SwMessageOrderType } from "@/types";
|
||||
import { getAccountFromId } from "@/scripts/get-account-from-id";
|
||||
import { getUrlWithLoginId } from "@/scripts/login-id";
|
||||
|
||||
export const cli = new Misskey.api.APIClient({
|
||||
origin,
|
||||
fetch: (...args): Promise<Response> => fetch(...args),
|
||||
});
|
||||
|
||||
export async function api<
|
||||
E extends keyof Misskey.Endpoints,
|
||||
O extends Misskey.Endpoints[E]["req"],
|
||||
>(
|
||||
endpoint: E,
|
||||
userId?: string,
|
||||
options?: O,
|
||||
): Promise<void | ReturnType<typeof cli.request<E, O>>> {
|
||||
let account: { token: string; id: string } | void;
|
||||
|
||||
if (userId) {
|
||||
account = await getAccountFromId(userId);
|
||||
if (!account) return;
|
||||
}
|
||||
|
||||
return cli.request(endpoint, options, account?.token);
|
||||
}
|
||||
|
||||
// mark-all-as-read送出を1秒間隔に制限する
|
||||
const readBlockingStatus = new Map<string, boolean>();
|
||||
export function sendMarkAllAsRead(
|
||||
userId: string,
|
||||
): Promise<null | undefined | void> {
|
||||
if (readBlockingStatus.get(userId)) return Promise.resolve();
|
||||
readBlockingStatus.set(userId, true);
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
readBlockingStatus.set(userId, false);
|
||||
api("notifications/mark-all-as-read", userId).then(resolve, resolve);
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
// rendered acctからユーザーを開く
|
||||
export function openUser(
|
||||
acct: string,
|
||||
loginId?: string,
|
||||
): ReturnType<typeof openClient> {
|
||||
return openClient("push", `/@${acct}`, loginId, { acct });
|
||||
}
|
||||
|
||||
// noteIdからノートを開く
|
||||
export function openNote(
|
||||
noteId: string,
|
||||
loginId?: string,
|
||||
): ReturnType<typeof openClient> {
|
||||
return openClient("push", `/notes/${noteId}`, loginId, { noteId });
|
||||
}
|
||||
|
||||
// noteIdからノートを開く
|
||||
export function openAntenna(
|
||||
antennaId: string,
|
||||
loginId: string,
|
||||
): ReturnType<typeof openClient> {
|
||||
return openClient("push", `/timeline/antenna/${antennaId}`, loginId, {
|
||||
antennaId,
|
||||
});
|
||||
}
|
||||
|
||||
// post-formのオプションから投稿フォームを開く
|
||||
export async function openPost(
|
||||
options: {
|
||||
initialText?: string;
|
||||
reply?: Misskey.entities.Note;
|
||||
renote?: Misskey.entities.Note;
|
||||
},
|
||||
loginId?: string,
|
||||
): ReturnType<typeof openClient> {
|
||||
// クエリを作成しておく
|
||||
const url = "/share";
|
||||
const query = new URLSearchParams();
|
||||
if (options.initialText) query.set("text", options.initialText);
|
||||
if (options.reply) query.set("replyId", options.reply.id);
|
||||
if (options.renote) query.set("renoteId", options.renote.id);
|
||||
|
||||
return openClient("post", `${url}?${query}`, loginId, { options });
|
||||
}
|
||||
|
||||
export async function openClient(
|
||||
order: SwMessageOrderType,
|
||||
url: string,
|
||||
loginId?: string,
|
||||
query: Record<string, SwMessage[string]> = {},
|
||||
): Promise<WindowClient | null> {
|
||||
const client = await findClient();
|
||||
|
||||
if (client) {
|
||||
client.postMessage({
|
||||
type: "order",
|
||||
...query,
|
||||
order,
|
||||
loginId,
|
||||
url,
|
||||
} satisfies SwMessage);
|
||||
return client;
|
||||
}
|
||||
|
||||
return self.clients.openWindow(getUrlWithLoginId(url, loginId!));
|
||||
}
|
||||
|
||||
export async function findClient(): Promise<WindowClient | null> {
|
||||
const clients = await globalThis.clients.matchAll({
|
||||
type: "window",
|
||||
});
|
||||
return clients.find((c) => !new URL(c.url).searchParams.has("zen")) ?? null;
|
||||
}
|
8
packages/sw/src/scripts/twemoji-base.ts
Normal file
8
packages/sw/src/scripts/twemoji-base.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
export function char2fileName(char: string): string {
|
||||
let codes = Array.from(char)
|
||||
.map((x) => x.codePointAt(0)?.toString(16))
|
||||
.filter(<T>(x: T | undefined): x is T => x !== undefined);
|
||||
if (!codes.includes("200d")) codes = codes.filter((x) => x !== "fe0f");
|
||||
codes = codes.filter((x) => x.length !== 0);
|
||||
return codes.join("-");
|
||||
}
|
282
packages/sw/src/sw.ts
Normal file
282
packages/sw/src/sw.ts
Normal file
|
@ -0,0 +1,282 @@
|
|||
import { get } from "idb-keyval";
|
||||
import * as Acct from "firefish-js/built/acct";
|
||||
import type { PushNotificationDataMap } from "@/types";
|
||||
import {
|
||||
createEmptyNotification,
|
||||
createNotification,
|
||||
} from "@/scripts/create-notification";
|
||||
import { swLang } from "@/scripts/lang";
|
||||
import * as swos from "@/scripts/operations";
|
||||
|
||||
globalThis.addEventListener("install", () => {
|
||||
// ev.waitUntil(globalThis.skipWaiting());
|
||||
});
|
||||
|
||||
globalThis.addEventListener("activate", (ev) => {
|
||||
ev.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((cacheNames) =>
|
||||
Promise.all(
|
||||
cacheNames
|
||||
.filter((v) => v !== swLang.cacheName)
|
||||
.map((name) => caches.delete(name)),
|
||||
),
|
||||
)
|
||||
.then(() => globalThis.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
function offlineContentHTML(): string {
|
||||
return `<!doctype html>Offline. Service Worker @${_VERSION_} <button onclick="location.reload()">reload</button>`;
|
||||
}
|
||||
|
||||
globalThis.addEventListener("fetch", (ev) => {
|
||||
let isHTMLRequest = false;
|
||||
if (ev.request.headers.get("sec-fetch-dest") === "document") {
|
||||
isHTMLRequest = true;
|
||||
} else if (ev.request.headers.get("accept")?.includes("/html")) {
|
||||
isHTMLRequest = true;
|
||||
} else if (ev.request.url.endsWith("/")) {
|
||||
isHTMLRequest = true;
|
||||
}
|
||||
|
||||
if (!isHTMLRequest) return;
|
||||
ev.respondWith(
|
||||
fetch(ev.request).catch(() => {
|
||||
return new Response(offlineContentHTML(), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/html",
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
globalThis.addEventListener("push", (ev) => {
|
||||
// クライアント取得
|
||||
ev.waitUntil(
|
||||
globalThis.clients
|
||||
.matchAll({
|
||||
includeUncontrolled: true,
|
||||
type: "window",
|
||||
})
|
||||
.then(async () => {
|
||||
const data: PushNotificationDataMap[keyof PushNotificationDataMap] =
|
||||
ev.data?.json();
|
||||
|
||||
switch (data.type) {
|
||||
// case 'driveFileCreated':
|
||||
case "notification":
|
||||
case "unreadAntennaNote":
|
||||
// 1日以上経過している場合は無視
|
||||
if (new Date().getTime() - data.dateTime > 1000 * 60 * 60 * 24)
|
||||
break;
|
||||
|
||||
return createNotification(data);
|
||||
case "readAllNotifications":
|
||||
await globalThis.registration
|
||||
.getNotifications()
|
||||
.then((notifications) =>
|
||||
notifications.forEach(
|
||||
(n) => n.tag !== "read_notification" && n.close(),
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
await createEmptyNotification();
|
||||
return;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
(globalThis as unknown as ServiceWorkerGlobalScope).addEventListener(
|
||||
"notificationclick",
|
||||
(ev: ServiceWorkerGlobalScopeEventMap["notificationclick"]) => {
|
||||
ev.waitUntil(
|
||||
(async (): Promise<void> => {
|
||||
if (_DEV_) {
|
||||
console.log("notificationclick", ev.action, ev.notification.data);
|
||||
}
|
||||
|
||||
const { action, notification } = ev;
|
||||
const data: PushNotificationDataMap[keyof PushNotificationDataMap] =
|
||||
notification.data ?? {};
|
||||
const { userId: loginId } = 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", loginId, {
|
||||
userId: data.body.userId,
|
||||
});
|
||||
break;
|
||||
case "showUser":
|
||||
if ("user" in data.body)
|
||||
client = await swos.openUser(
|
||||
Acct.toString(data.body.user),
|
||||
loginId,
|
||||
);
|
||||
break;
|
||||
case "reply":
|
||||
if ("note" in data.body)
|
||||
client = await swos.openPost(
|
||||
{ reply: data.body.note },
|
||||
loginId,
|
||||
);
|
||||
break;
|
||||
case "renote":
|
||||
if ("note" in data.body)
|
||||
await swos.api("notes/create", loginId, {
|
||||
renoteId: data.body.note.id,
|
||||
});
|
||||
break;
|
||||
case "accept":
|
||||
switch (data.body.type) {
|
||||
case "receiveFollowRequest":
|
||||
await swos.api("following/requests/accept", loginId, {
|
||||
userId: data.body.userId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case "reject":
|
||||
switch (data.body.type) {
|
||||
case "receiveFollowRequest":
|
||||
await swos.api("following/requests/reject", loginId, {
|
||||
userId: data.body.userId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case "showFollowRequests":
|
||||
client = await swos.openClient(
|
||||
"push",
|
||||
"/my/follow-requests",
|
||||
loginId,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
switch (data.body.type) {
|
||||
case "receiveFollowRequest":
|
||||
client = await swos.openClient(
|
||||
"push",
|
||||
"/my/follow-requests",
|
||||
loginId,
|
||||
);
|
||||
break;
|
||||
case "reaction":
|
||||
client = await swos.openNote(data.body.note.id, loginId);
|
||||
break;
|
||||
default:
|
||||
if ("note" in data.body) {
|
||||
client = await swos.openNote(data.body.note.id, loginId);
|
||||
} else if ("user" in data.body) {
|
||||
client = await swos.openUser(
|
||||
Acct.toString(data.body.user),
|
||||
loginId,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "unreadAntennaNote":
|
||||
client = await swos.openAntenna(data.body.antenna.id, loginId);
|
||||
break;
|
||||
default:
|
||||
switch (action) {
|
||||
case "markAllAsRead":
|
||||
await globalThis.registration
|
||||
.getNotifications()
|
||||
.then((notifications) =>
|
||||
notifications.forEach(
|
||||
(n) => n.tag !== "read_notification" && n.close(),
|
||||
),
|
||||
);
|
||||
await get("accounts").then((accounts) => {
|
||||
return Promise.all(
|
||||
accounts.map(async (account) => {
|
||||
await swos.sendMarkAllAsRead(account.id);
|
||||
}),
|
||||
);
|
||||
});
|
||||
break;
|
||||
case "settings":
|
||||
client = await swos.openClient(
|
||||
"push",
|
||||
"/settings/notifications",
|
||||
loginId,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (client) {
|
||||
client.focus();
|
||||
}
|
||||
if (data.type === "notification") {
|
||||
await swos.sendMarkAllAsRead(loginId);
|
||||
}
|
||||
|
||||
notification.close();
|
||||
})(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
(globalThis as unknown as ServiceWorkerGlobalScope).addEventListener(
|
||||
"notificationclose",
|
||||
(ev: ServiceWorkerGlobalScopeEventMap["notificationclose"]) => {
|
||||
const data: PushNotificationDataMap[keyof PushNotificationDataMap] =
|
||||
ev.notification.data;
|
||||
|
||||
ev.waitUntil(
|
||||
(async (): Promise<void> => {
|
||||
if (data.type === "notification") {
|
||||
await swos.sendMarkAllAsRead(data.userId);
|
||||
}
|
||||
return;
|
||||
})(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
(globalThis as unknown as ServiceWorkerGlobalScope).addEventListener(
|
||||
"message",
|
||||
(ev: ServiceWorkerGlobalScopeEventMap["message"]) => {
|
||||
ev.waitUntil(
|
||||
(async (): Promise<void> => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
})(),
|
||||
);
|
||||
},
|
||||
);
|
51
packages/sw/src/types.ts
Normal file
51
packages/sw/src/types.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
import * as Misskey from "firefish-js";
|
||||
|
||||
export type SwMessageOrderType = "post" | "push";
|
||||
|
||||
export type SwMessage = {
|
||||
type: "order";
|
||||
order: SwMessageOrderType;
|
||||
loginId?: string;
|
||||
url: string;
|
||||
[x: string]: unknown;
|
||||
};
|
||||
|
||||
// Defined also @/services/push-notification.ts#L7-L14
|
||||
type PushNotificationDataSourceMap = {
|
||||
notification: Misskey.entities.Notification;
|
||||
unreadAntennaNote: {
|
||||
antenna: { id: string; name: string };
|
||||
note: Misskey.entities.Note;
|
||||
};
|
||||
readAllNotifications: undefined;
|
||||
readAllMessagingMessages: undefined;
|
||||
readAllMessagingMessagesOfARoom: { userId: string } | { groupId: string };
|
||||
};
|
||||
|
||||
export type PushNotificationData<
|
||||
K extends keyof PushNotificationDataSourceMap,
|
||||
> = {
|
||||
type: K;
|
||||
body: PushNotificationDataSourceMap[K];
|
||||
userId: string;
|
||||
dateTime: number;
|
||||
};
|
||||
|
||||
export type PushNotificationDataMap = {
|
||||
[K in keyof PushNotificationDataSourceMap]: PushNotificationData<K>;
|
||||
};
|
||||
|
||||
export type BadgeNames =
|
||||
| "null"
|
||||
| "antenna"
|
||||
| "arrow-back-up"
|
||||
| "at"
|
||||
| "chart-arrows"
|
||||
| "circle-check"
|
||||
| "medal"
|
||||
| "messages"
|
||||
| "plus"
|
||||
| "quote"
|
||||
| "repeat"
|
||||
| "user-plus"
|
||||
| "users";
|
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,
|
||||
"isolatedModules": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
},
|
||||
"typeRoots": [
|
||||
"node_modules/@types",
|
||||
"@types",
|
||||
],
|
||||
"lib": [
|
||||
"esnext",
|
||||
"webworker"
|
||||
]
|
||||
},
|
||||
"compileOnSave": false,
|
||||
"include": [
|
||||
"./**/*.ts", "./**/*.d.ts", "./**/*.tsx", "./**/*.vue"
|
||||
]
|
||||
}
|
50
packages/sw/webpack.config.js
Normal file
50
packages/sw/webpack.config.js
Normal file
|
@ -0,0 +1,50 @@
|
|||
const webpack = require("webpack");
|
||||
const path = require("path");
|
||||
const locales = require("../../locales");
|
||||
const meta = require("../../package.json");
|
||||
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
|
||||
module.exports = {
|
||||
mode: isProduction ? "production" : "development",
|
||||
stats: "errors-only",
|
||||
entry: "./src/sw.ts",
|
||||
output: {
|
||||
path: path.resolve(__dirname, "../../built/_sw_dist_"),
|
||||
filename: "sw.js",
|
||||
},
|
||||
resolve: {
|
||||
extensions: [".js", ".ts"],
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.ts$/,
|
||||
exclude: /(node_modules|bower_components)/,
|
||||
use: {
|
||||
loader: "swc-loader",
|
||||
options: {
|
||||
// This makes swc-loader invoke swc synchronously.
|
||||
sync: true,
|
||||
jsc: {
|
||||
parser: {
|
||||
syntax: "typescript",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
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_: !isProduction,
|
||||
_PERF_PREFIX_: JSON.stringify("Firefish:"),
|
||||
}),
|
||||
],
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue