Merge remote-tracking branch 'misskey/master' into feature/2024.9.0
This commit is contained in:
commit
f00576bce6
564 changed files with 19993 additions and 8169 deletions
22
packages/frontend-shared/js/collapsed.ts
Normal file
22
packages/frontend-shared/js/collapsed.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as Misskey from 'misskey-js';
|
||||
|
||||
export function shouldCollapsed(note: Misskey.entities.Note, urls: string[]): boolean {
|
||||
const collapsed = note.cw == null && (
|
||||
(note.text != null && (
|
||||
(note.text.includes('$[x2')) ||
|
||||
(note.text.includes('$[x3')) ||
|
||||
(note.text.includes('$[x4')) ||
|
||||
(note.text.includes('$[scale')) ||
|
||||
(note.text.split('\n').length > 9) ||
|
||||
(note.text.length > 500) ||
|
||||
(urls.length >= 4)
|
||||
)) || (note.files != null && note.files.length >= 5)
|
||||
);
|
||||
|
||||
return collapsed;
|
||||
}
|
||||
28
packages/frontend-shared/js/config.ts
Normal file
28
packages/frontend-shared/js/config.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Locale } from '../../../locales/index.js';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
const address = new URL(document.querySelector<HTMLMetaElement>('meta[property="instance_url"]')?.content || location.href);
|
||||
const siteName = document.querySelector<HTMLMetaElement>('meta[property="og:site_name"]')?.content;
|
||||
|
||||
export const host = address.host;
|
||||
export const hostname = address.hostname;
|
||||
export const url = address.origin;
|
||||
export const apiUrl = location.origin + '/api';
|
||||
export const wsOrigin = location.origin;
|
||||
export const lang = localStorage.getItem('lang') ?? 'en-US';
|
||||
export const langs = _LANGS_;
|
||||
const preParseLocale = localStorage.getItem('locale');
|
||||
export let locale: Locale = preParseLocale ? JSON.parse(preParseLocale) : null;
|
||||
export const version = _VERSION_;
|
||||
export const instanceName = (siteName === 'Sharkey' || siteName == null) ? host : siteName;
|
||||
export const ui = localStorage.getItem('ui');
|
||||
export const debug = localStorage.getItem('debug') === 'true';
|
||||
|
||||
export function updateLocale(newLocale: Locale): void {
|
||||
locale = newLocale;
|
||||
}
|
||||
201
packages/frontend-shared/js/const.ts
Normal file
201
packages/frontend-shared/js/const.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
// ブラウザで直接表示することを許可するファイルの種類のリスト
|
||||
// ここに含まれないものは application/octet-stream としてレスポンスされる
|
||||
// SVGはXSSを生むので許可しない
|
||||
export const FILE_TYPE_BROWSERSAFE = [
|
||||
// Images
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
'image/avif',
|
||||
'image/apng',
|
||||
'image/bmp',
|
||||
'image/tiff',
|
||||
'image/x-icon',
|
||||
'image/jxl',
|
||||
|
||||
// OggS
|
||||
'audio/opus',
|
||||
'video/ogg',
|
||||
'audio/ogg',
|
||||
'application/ogg',
|
||||
|
||||
// ISO/IEC base media file format
|
||||
'video/quicktime',
|
||||
'video/mp4',
|
||||
'audio/mp4',
|
||||
'video/x-m4v',
|
||||
'audio/x-m4a',
|
||||
'video/3gpp',
|
||||
'video/3gpp2',
|
||||
|
||||
'video/mpeg',
|
||||
'audio/mpeg',
|
||||
|
||||
'video/webm',
|
||||
'audio/webm',
|
||||
|
||||
'audio/aac',
|
||||
|
||||
// see https://github.com/misskey-dev/misskey/pull/10686
|
||||
'audio/flac',
|
||||
'audio/wav',
|
||||
// backward compatibility
|
||||
'audio/x-flac',
|
||||
'audio/vnd.wave',
|
||||
];
|
||||
|
||||
export const FILE_TYPE_TRACKER_MODULES = [
|
||||
'audio/mod',
|
||||
'audio/x-mod',
|
||||
'audio/s3m',
|
||||
'audio/x-s3m',
|
||||
'audio/xm',
|
||||
'audio/x-xm',
|
||||
'audio/it',
|
||||
'audio/x-it',
|
||||
];
|
||||
|
||||
export const FILE_EXT_TRACKER_MODULES = [
|
||||
'mod',
|
||||
's3m',
|
||||
'xm',
|
||||
'it',
|
||||
'mptm',
|
||||
'stm',
|
||||
'nst',
|
||||
'm15',
|
||||
'stk',
|
||||
'wow',
|
||||
'ult',
|
||||
'669',
|
||||
'mtm',
|
||||
'med',
|
||||
'far',
|
||||
'mdl',
|
||||
'ams',
|
||||
'dsm',
|
||||
'amf',
|
||||
'okt',
|
||||
'dmf',
|
||||
'ptm',
|
||||
'psm',
|
||||
'mt2',
|
||||
'dbm',
|
||||
'digi',
|
||||
'imf',
|
||||
'j2b',
|
||||
'gdm',
|
||||
'umx',
|
||||
'plm',
|
||||
'mo3',
|
||||
'xpk',
|
||||
'ppm',
|
||||
'mmcmp',
|
||||
];
|
||||
|
||||
/*
|
||||
https://github.com/sindresorhus/file-type/blob/main/supported.js
|
||||
https://github.com/sindresorhus/file-type/blob/main/core.js
|
||||
https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Containers
|
||||
*/
|
||||
|
||||
export const notificationTypes = [
|
||||
'note',
|
||||
'follow',
|
||||
'mention',
|
||||
'reply',
|
||||
'renote',
|
||||
'quote',
|
||||
'reaction',
|
||||
'pollEnded',
|
||||
'receiveFollowRequest',
|
||||
'followRequestAccepted',
|
||||
'roleAssigned',
|
||||
'achievementEarned',
|
||||
'exportCompleted',
|
||||
'test',
|
||||
'app',
|
||||
'edited'
|
||||
] as const;
|
||||
export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const;
|
||||
|
||||
export const ROLE_POLICIES = [
|
||||
'gtlAvailable',
|
||||
'ltlAvailable',
|
||||
'btlAvailable',
|
||||
'canPublicNote',
|
||||
'canImportNotes',
|
||||
'mentionLimit',
|
||||
'canInvite',
|
||||
'inviteLimit',
|
||||
'inviteLimitCycle',
|
||||
'inviteExpirationTime',
|
||||
'canManageCustomEmojis',
|
||||
'canManageAvatarDecorations',
|
||||
'canSearchNotes',
|
||||
'canUseTranslator',
|
||||
'canHideAds',
|
||||
'driveCapacityMb',
|
||||
'alwaysMarkNsfw',
|
||||
'canUpdateBioMedia',
|
||||
'pinLimit',
|
||||
'antennaLimit',
|
||||
'wordMuteLimit',
|
||||
'webhookLimit',
|
||||
'clipLimit',
|
||||
'noteEachClipsLimit',
|
||||
'userListLimit',
|
||||
'userEachUserListsLimit',
|
||||
'rateLimitFactor',
|
||||
'avatarDecorationLimit',
|
||||
'canImportAntennas',
|
||||
'canImportBlocking',
|
||||
'canImportFollowing',
|
||||
'canImportMuting',
|
||||
'canImportUserLists',
|
||||
] as const;
|
||||
|
||||
// なんか動かない
|
||||
//export const CURRENT_STICKY_TOP = Symbol('CURRENT_STICKY_TOP');
|
||||
//export const CURRENT_STICKY_BOTTOM = Symbol('CURRENT_STICKY_BOTTOM');
|
||||
export const CURRENT_STICKY_TOP = 'CURRENT_STICKY_TOP';
|
||||
export const CURRENT_STICKY_BOTTOM = 'CURRENT_STICKY_BOTTOM';
|
||||
|
||||
export const DEFAULT_SERVER_ERROR_IMAGE_URL = '/status/error.png';
|
||||
export const DEFAULT_NOT_FOUND_IMAGE_URL = '/status/missingpage.webp';
|
||||
export const DEFAULT_INFO_IMAGE_URL = '/status/nothinghere.png';
|
||||
|
||||
export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'border', 'font', 'blur', 'rainbow', 'sparkle', 'rotate', 'ruby', 'unixtime', 'crop', 'fade', 'followmouse'];
|
||||
export const MFM_PARAMS: Record<typeof MFM_TAGS[number], string[]> = {
|
||||
tada: ['speed=', 'delay='],
|
||||
jelly: ['speed=', 'delay='],
|
||||
twitch: ['speed=', 'delay='],
|
||||
shake: ['speed=', 'delay='],
|
||||
spin: ['speed=', 'delay=', 'left', 'alternate', 'x', 'y'],
|
||||
jump: ['speed=', 'delay='],
|
||||
bounce: ['speed=', 'delay='],
|
||||
flip: ['h', 'v'],
|
||||
x2: [],
|
||||
x3: [],
|
||||
x4: [],
|
||||
scale: ['x=', 'y='],
|
||||
position: ['x=', 'y='],
|
||||
fg: ['color='],
|
||||
bg: ['color='],
|
||||
border: ['width=', 'style=', 'color=', 'radius=', 'noclip'],
|
||||
font: ['serif', 'monospace', 'cursive', 'fantasy', 'emoji', 'math'],
|
||||
blur: [],
|
||||
rainbow: ['speed=', 'delay='],
|
||||
rotate: ['deg='],
|
||||
ruby: [],
|
||||
unixtime: [],
|
||||
fade: ['speed=', 'delay=', 'loop=', 'out'],
|
||||
crop: ['top=', 'bottom=', 'left=', 'right='],
|
||||
followmouse: ['x', 'y', 'rotateByVelocity', 'speed='],
|
||||
};
|
||||
97
packages/frontend-shared/js/embed-page.ts
Normal file
97
packages/frontend-shared/js/embed-page.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//#region Embed関連の定義
|
||||
|
||||
/** 埋め込みの対象となるエンティティ(/embed/xxx の xxx の部分と対応させる) */
|
||||
const embeddableEntities = [
|
||||
'notes',
|
||||
'user-timeline',
|
||||
'clips',
|
||||
'tags',
|
||||
] as const;
|
||||
|
||||
/** 埋め込みの対象となるエンティティ */
|
||||
export type EmbeddableEntity = typeof embeddableEntities[number];
|
||||
|
||||
/** 内部でスクロールがあるページ */
|
||||
export const embedRouteWithScrollbar: EmbeddableEntity[] = [
|
||||
'clips',
|
||||
'tags',
|
||||
'user-timeline',
|
||||
];
|
||||
|
||||
/** 埋め込みコードのパラメータ */
|
||||
export type EmbedParams = {
|
||||
maxHeight?: number;
|
||||
colorMode?: 'light' | 'dark';
|
||||
rounded?: boolean;
|
||||
border?: boolean;
|
||||
autoload?: boolean;
|
||||
header?: boolean;
|
||||
};
|
||||
|
||||
/** 正規化されたパラメータ */
|
||||
export type ParsedEmbedParams = Required<Omit<EmbedParams, 'maxHeight' | 'colorMode'>> & Pick<EmbedParams, 'maxHeight' | 'colorMode'>;
|
||||
|
||||
/** パラメータのデフォルトの値 */
|
||||
export const defaultEmbedParams = {
|
||||
maxHeight: undefined,
|
||||
colorMode: undefined,
|
||||
rounded: true,
|
||||
border: true,
|
||||
autoload: false,
|
||||
header: true,
|
||||
} as const satisfies EmbedParams;
|
||||
|
||||
//#endregion
|
||||
|
||||
/**
|
||||
* パラメータを正規化する(埋め込みページ初期化用)
|
||||
* @param searchParams URLSearchParamsもしくはクエリ文字列
|
||||
* @returns 正規化されたパラメータ
|
||||
*/
|
||||
export function parseEmbedParams(searchParams: URLSearchParams | string): ParsedEmbedParams {
|
||||
let _searchParams: URLSearchParams;
|
||||
if (typeof searchParams === 'string') {
|
||||
_searchParams = new URLSearchParams(searchParams);
|
||||
} else if (searchParams instanceof URLSearchParams) {
|
||||
_searchParams = searchParams;
|
||||
} else {
|
||||
throw new Error('searchParams must be URLSearchParams or string');
|
||||
}
|
||||
|
||||
function convertBoolean(value: string | null): boolean | undefined {
|
||||
if (value === 'true') {
|
||||
return true;
|
||||
} else if (value === 'false') {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function convertNumber(value: string | null): number | undefined {
|
||||
if (value != null && !isNaN(Number(value))) {
|
||||
return Number(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function convertColorMode(value: string | null): 'light' | 'dark' | undefined {
|
||||
if (value != null && ['light', 'dark'].includes(value)) {
|
||||
return value as 'light' | 'dark';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
maxHeight: convertNumber(_searchParams.get('maxHeight')) ?? defaultEmbedParams.maxHeight,
|
||||
colorMode: convertColorMode(_searchParams.get('colorMode')) ?? defaultEmbedParams.colorMode,
|
||||
rounded: convertBoolean(_searchParams.get('rounded')) ?? defaultEmbedParams.rounded,
|
||||
border: convertBoolean(_searchParams.get('border')) ?? defaultEmbedParams.border,
|
||||
autoload: convertBoolean(_searchParams.get('autoload')) ?? defaultEmbedParams.autoload,
|
||||
header: convertBoolean(_searchParams.get('header')) ?? defaultEmbedParams.header,
|
||||
};
|
||||
}
|
||||
37
packages/frontend-shared/js/emoji-base.ts
Normal file
37
packages/frontend-shared/js/emoji-base.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
const twemojiSvgBase = '/twemoji';
|
||||
const fluentEmojiPngBase = '/fluent-emoji';
|
||||
const tossfaceSvgBase = '/tossface';
|
||||
|
||||
export function char2twemojiFilePath(char: string): string {
|
||||
let codes = Array.from(char, x => x.codePointAt(0)?.toString(16));
|
||||
if (!codes.includes('200d')) codes = codes.filter(x => x !== 'fe0f');
|
||||
codes = codes.filter(x => x && x.length);
|
||||
const fileName = codes.join('-');
|
||||
return `${twemojiSvgBase}/${fileName}.svg`;
|
||||
}
|
||||
|
||||
export function char2fluentEmojiFilePath(char: string): string {
|
||||
let codes = Array.from(char, x => x.codePointAt(0)?.toString(16));
|
||||
// Fluent Emojiは国旗非対応 https://github.com/microsoft/fluentui-emoji/issues/25
|
||||
if (codes[0]?.startsWith('1f1')) return char2twemojiFilePath(char);
|
||||
if (!codes.includes('200d')) codes = codes.filter(x => x !== 'fe0f');
|
||||
codes = codes.filter(x => x != null && x.length > 0);
|
||||
const fileName = (codes as string[]).map(x => x.padStart(4, '0')).join('-');
|
||||
return `${fluentEmojiPngBase}/${fileName}.png`;
|
||||
}
|
||||
|
||||
export function char2tossfaceFilePath(char: string): string {
|
||||
let codes = Array.from(char, x => x.codePointAt(0)?.toString(16));
|
||||
// Twemoji is the only emoji font which still supports the shibuya 50 emoji to this day
|
||||
if (codes[0]?.startsWith('e50a')) return char2twemojiFilePath(char);
|
||||
// Tossface does not use the fe0f modifier
|
||||
codes = codes.filter(x => x !== 'fe0f');
|
||||
codes = codes.filter(x => x && x.length);
|
||||
const fileName = codes.join('-');
|
||||
return `${tossfaceSvgBase}/${fileName}.svg`;
|
||||
}
|
||||
1805
packages/frontend-shared/js/emojilist.json
Normal file
1805
packages/frontend-shared/js/emojilist.json
Normal file
File diff suppressed because it is too large
Load diff
73
packages/frontend-shared/js/emojilist.ts
Normal file
73
packages/frontend-shared/js/emojilist.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export const unicodeEmojiCategories = ['face', 'people', 'animals_and_nature', 'food_and_drink', 'activity', 'travel_and_places', 'objects', 'symbols', 'flags'] as const;
|
||||
|
||||
export type UnicodeEmojiDef = {
|
||||
name: string;
|
||||
char: string;
|
||||
category: typeof unicodeEmojiCategories[number];
|
||||
}
|
||||
|
||||
// initial converted from https://github.com/muan/emojilib/commit/242fe68be86ed6536843b83f7e32f376468b38fb
|
||||
import _emojilist from './emojilist.json';
|
||||
|
||||
export const emojilist: UnicodeEmojiDef[] = _emojilist.map(x => ({
|
||||
name: x[1] as string,
|
||||
char: x[0] as string,
|
||||
category: unicodeEmojiCategories[x[2] as number],
|
||||
}));
|
||||
|
||||
const unicodeEmojisMap = new Map<string, UnicodeEmojiDef>(
|
||||
emojilist.map(x => [x.char, x]),
|
||||
);
|
||||
|
||||
const _indexByChar = new Map<string, number>();
|
||||
const _charGroupByCategory = new Map<string, string[]>();
|
||||
for (let i = 0; i < emojilist.length; i++) {
|
||||
const emo = emojilist[i];
|
||||
_indexByChar.set(emo.char, i);
|
||||
|
||||
if (_charGroupByCategory.has(emo.category)) {
|
||||
_charGroupByCategory.get(emo.category)?.push(emo.char);
|
||||
} else {
|
||||
_charGroupByCategory.set(emo.category, [emo.char]);
|
||||
}
|
||||
}
|
||||
|
||||
export const emojiCharByCategory = _charGroupByCategory;
|
||||
|
||||
export function getUnicodeEmoji(char: string): UnicodeEmojiDef | string {
|
||||
// Colorize it because emojilist.json assumes that
|
||||
return unicodeEmojisMap.get(colorizeEmoji(char))
|
||||
// カラースタイル絵文字がjsonに無い場合はテキストスタイル絵文字にフォールバックする
|
||||
?? unicodeEmojisMap.get(char)
|
||||
// それでも見つからない場合はそのまま返す(絵文字情報がjsonに無い場合、このフォールバックが無いとレンダリングに失敗する)
|
||||
?? char;
|
||||
}
|
||||
|
||||
export function getEmojiName(char: string): string {
|
||||
// Colorize it because emojilist.json assumes that
|
||||
const idx = _indexByChar.get(colorizeEmoji(char)) ?? _indexByChar.get(char);
|
||||
if (idx === undefined) {
|
||||
// 絵文字情報がjsonに無い場合は名前の取得が出来ないのでそのまま返すしか無い
|
||||
return char;
|
||||
} else {
|
||||
return emojilist[idx].name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* テキストスタイル絵文字(U+260Eなどの1文字で表現される絵文字)をカラースタイル絵文字に変換します(VS16:U+FE0Fを付与)。
|
||||
*/
|
||||
export function colorizeEmoji(char: string) {
|
||||
return char.length === 1 ? `${char}\uFE0F` : char;
|
||||
}
|
||||
|
||||
export interface CustomEmojiFolderTree {
|
||||
value: string;
|
||||
category: string;
|
||||
children: CustomEmojiFolderTree[];
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export function extractAvgColorFromBlurhash(hash: string) {
|
||||
return typeof hash === 'string'
|
||||
? '#' + [...hash.slice(2, 6)]
|
||||
.map(x => '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~'.indexOf(x))
|
||||
.reduce((a, c) => a * 83 + c, 0)
|
||||
.toString(16)
|
||||
.padStart(6, '0')
|
||||
: undefined;
|
||||
}
|
||||
251
packages/frontend-shared/js/i18n.ts
Normal file
251
packages/frontend-shared/js/i18n.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { ILocale, ParameterizedString } from '../../../locales/index.js';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type TODO = any;
|
||||
|
||||
type FlattenKeys<T extends ILocale, TPrediction> = keyof {
|
||||
[K in keyof T as T[K] extends ILocale
|
||||
? FlattenKeys<T[K], TPrediction> extends infer C extends string
|
||||
? `${K & string}.${C}`
|
||||
: never
|
||||
: T[K] extends TPrediction
|
||||
? K
|
||||
: never]: T[K];
|
||||
};
|
||||
|
||||
type ParametersOf<T extends ILocale, TKey extends FlattenKeys<T, ParameterizedString>> = TKey extends `${infer K}.${infer C}`
|
||||
// @ts-expect-error -- C は明らかに FlattenKeys<T[K], ParameterizedString> になるが、型システムはここでは TKey がドット区切りであることのコンテキストを持たないので、型システムに合法にて示すことはできない。
|
||||
? ParametersOf<T[K], C>
|
||||
: TKey extends keyof T
|
||||
? T[TKey] extends ParameterizedString<infer P>
|
||||
? P
|
||||
: never
|
||||
: never;
|
||||
|
||||
type Tsx<T extends ILocale> = {
|
||||
readonly [K in keyof T as T[K] extends string ? never : K]: T[K] extends ParameterizedString<infer P>
|
||||
? (arg: { readonly [_ in P]: string | number }) => string
|
||||
// @ts-expect-error -- 証明省略
|
||||
: Tsx<T[K]>;
|
||||
};
|
||||
|
||||
export class I18n<T extends ILocale> {
|
||||
private tsxCache?: Tsx<T>;
|
||||
private devMode: boolean;
|
||||
|
||||
constructor(public locale: T, devMode = false) {
|
||||
this.devMode = devMode;
|
||||
|
||||
//#region BIND
|
||||
this.t = this.t.bind(this);
|
||||
//#endregion
|
||||
}
|
||||
|
||||
public get ts(): T {
|
||||
if (this.devMode) {
|
||||
class Handler<TTarget extends ILocale> implements ProxyHandler<TTarget> {
|
||||
get(target: TTarget, p: string | symbol): unknown {
|
||||
const value = target[p as keyof TTarget];
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return new Proxy(value, new Handler<TTarget[keyof TTarget] & ILocale>());
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const parameters = Array.from(value.matchAll(/\{(\w+)\}/g), ([, parameter]) => parameter);
|
||||
|
||||
if (parameters.length) {
|
||||
console.error(`Missing locale parameters: ${parameters.join(', ')} at ${String(p)}`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
console.error(`Unexpected locale key: ${String(p)}`);
|
||||
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
return new Proxy(this.locale, new Handler());
|
||||
}
|
||||
|
||||
return this.locale;
|
||||
}
|
||||
|
||||
public get tsx(): Tsx<T> {
|
||||
if (this.devMode) {
|
||||
if (this.tsxCache) {
|
||||
return this.tsxCache;
|
||||
}
|
||||
|
||||
class Handler<TTarget extends ILocale> implements ProxyHandler<TTarget> {
|
||||
get(target: TTarget, p: string | symbol): unknown {
|
||||
const value = target[p as keyof TTarget];
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return new Proxy(value, new Handler<TTarget[keyof TTarget] & ILocale>());
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const quasis: string[] = [];
|
||||
const expressions: string[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (~cursor) {
|
||||
const start = value.indexOf('{', cursor);
|
||||
|
||||
if (!~start) {
|
||||
quasis.push(value.slice(cursor));
|
||||
break;
|
||||
}
|
||||
|
||||
quasis.push(value.slice(cursor, start));
|
||||
|
||||
const end = value.indexOf('}', start);
|
||||
|
||||
expressions.push(value.slice(start + 1, end));
|
||||
|
||||
cursor = end + 1;
|
||||
}
|
||||
|
||||
if (!expressions.length) {
|
||||
console.error(`Unexpected locale key: ${String(p)}`);
|
||||
|
||||
return () => value;
|
||||
}
|
||||
|
||||
return (arg: TODO) => {
|
||||
let str = quasis[0];
|
||||
|
||||
for (let i = 0; i < expressions.length; i++) {
|
||||
if (!Object.hasOwn(arg, expressions[i])) {
|
||||
console.error(`Missing locale parameters: ${expressions[i]} at ${String(p)}`);
|
||||
}
|
||||
|
||||
str += arg[expressions[i]] + quasis[i + 1];
|
||||
}
|
||||
|
||||
return str;
|
||||
};
|
||||
}
|
||||
|
||||
console.error(`Unexpected locale key: ${String(p)}`);
|
||||
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
return this.tsxCache = new Proxy(this.locale, new Handler()) as unknown as Tsx<T>;
|
||||
}
|
||||
|
||||
if (this.tsxCache) {
|
||||
return this.tsxCache;
|
||||
}
|
||||
|
||||
function build(target: ILocale): Tsx<T> {
|
||||
const result = {} as Tsx<T>;
|
||||
|
||||
for (const k in target) {
|
||||
if (!Object.hasOwn(target, k)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = target[k as keyof typeof target];
|
||||
|
||||
if (typeof value === 'object') {
|
||||
(result as TODO)[k] = build(value as ILocale);
|
||||
} else if (typeof value === 'string') {
|
||||
const quasis: string[] = [];
|
||||
const expressions: string[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (~cursor) {
|
||||
const start = value.indexOf('{', cursor);
|
||||
|
||||
if (!~start) {
|
||||
quasis.push(value.slice(cursor));
|
||||
break;
|
||||
}
|
||||
|
||||
quasis.push(value.slice(cursor, start));
|
||||
|
||||
const end = value.indexOf('}', start);
|
||||
|
||||
expressions.push(value.slice(start + 1, end));
|
||||
|
||||
cursor = end + 1;
|
||||
}
|
||||
|
||||
if (!expressions.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
(result as TODO)[k] = (arg: TODO) => {
|
||||
let str = quasis[0];
|
||||
|
||||
for (let i = 0; i < expressions.length; i++) {
|
||||
str += arg[expressions[i]] + quasis[i + 1];
|
||||
}
|
||||
|
||||
return str;
|
||||
};
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return this.tsxCache = build(this.locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated なるべくこのメソッド使うよりも ts 直接参照の方が vue のキャッシュ効いてパフォーマンスが良いかも
|
||||
*/
|
||||
public t<TKey extends FlattenKeys<T, string>>(key: TKey): string;
|
||||
/**
|
||||
* @deprecated なるべくこのメソッド使うよりも tsx 直接参照の方が vue のキャッシュ効いてパフォーマンスが良いかも
|
||||
*/
|
||||
public t<TKey extends FlattenKeys<T, ParameterizedString>>(key: TKey, args: { readonly [_ in ParametersOf<T, TKey>]: string | number }): string;
|
||||
public t(key: string, args?: { readonly [_: string]: string | number }) {
|
||||
let str: string | ParameterizedString | ILocale = this.locale;
|
||||
|
||||
for (const k of key.split('.')) {
|
||||
str = (str as TODO)[k];
|
||||
|
||||
if (this.devMode) {
|
||||
if (typeof str === 'undefined') {
|
||||
console.error(`Unexpected locale key: ${key}`);
|
||||
return key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (args) {
|
||||
if (this.devMode) {
|
||||
const missing = Array.from((str as string).matchAll(/\{(\w+)\}/g), ([, parameter]) => parameter).filter(parameter => !Object.hasOwn(args, parameter));
|
||||
|
||||
if (missing.length) {
|
||||
console.error(`Missing locale parameters: ${missing.join(', ')} at ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [k, v] of Object.entries(args)) {
|
||||
const search = `{${k}}`;
|
||||
|
||||
if (this.devMode) {
|
||||
if (!(str as string).includes(search)) {
|
||||
console.error(`Unexpected locale parameter: ${k} at ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
str = (str as string).replace(search, v.toString());
|
||||
}
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
}
|
||||
51
packages/frontend-shared/js/intl-const.ts
Normal file
51
packages/frontend-shared/js/intl-const.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { lang } from '@@/js/config.js';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
export const versatileLang = (lang ?? 'ja-JP').replace('ja-KS', 'ja-JP');
|
||||
|
||||
let _dateTimeFormat: Intl.DateTimeFormat;
|
||||
try {
|
||||
_dateTimeFormat = new Intl.DateTimeFormat(versatileLang, {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric',
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(err);
|
||||
if (_DEV_) console.log('[Intl] Fallback to en-US');
|
||||
|
||||
// Fallback to en-US
|
||||
_dateTimeFormat = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric',
|
||||
});
|
||||
}
|
||||
export const dateTimeFormat = _dateTimeFormat;
|
||||
|
||||
export const timeZone = dateTimeFormat.resolvedOptions().timeZone;
|
||||
|
||||
export const hemisphere = /^(australia|pacific|antarctica|indian)\//i.test(timeZone) ? 'S' : 'N';
|
||||
|
||||
let _numberFormat: Intl.NumberFormat;
|
||||
try {
|
||||
_numberFormat = new Intl.NumberFormat(versatileLang);
|
||||
} catch (err) {
|
||||
console.warn(err);
|
||||
if (_DEV_) console.log('[Intl] Fallback to en-US');
|
||||
|
||||
// Fallback to en-US
|
||||
_numberFormat = new Intl.NumberFormat('en-US');
|
||||
}
|
||||
export const numberFormat = _numberFormat;
|
||||
12
packages/frontend-shared/js/is-link.ts
Normal file
12
packages/frontend-shared/js/is-link.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export function isLink(el: HTMLElement) {
|
||||
if (el.tagName === 'A') return true;
|
||||
if (el.parentElement) {
|
||||
return isLink(el.parentElement);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
63
packages/frontend-shared/js/media-proxy.ts
Normal file
63
packages/frontend-shared/js/media-proxy.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { query } from './url.js';
|
||||
|
||||
export class MediaProxy {
|
||||
private serverMetadata: Misskey.entities.MetaDetailed;
|
||||
private url: string;
|
||||
|
||||
constructor(serverMetadata: Misskey.entities.MetaDetailed, url: string) {
|
||||
this.serverMetadata = serverMetadata;
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
public getProxiedImageUrl(imageUrl: string, type?: 'preview' | 'emoji' | 'avatar', mustOrigin = false, noFallback = false): string {
|
||||
const localProxy = `${this.url}/proxy`;
|
||||
let _imageUrl = imageUrl;
|
||||
|
||||
if (imageUrl.startsWith(this.serverMetadata.mediaProxy + '/') || imageUrl.startsWith('/proxy/') || imageUrl.startsWith(localProxy + '/')) {
|
||||
// もう既にproxyっぽそうだったらurlを取り出す
|
||||
_imageUrl = (new URL(imageUrl)).searchParams.get('url') ?? imageUrl;
|
||||
}
|
||||
|
||||
return `${mustOrigin ? localProxy : this.serverMetadata.mediaProxy}/${
|
||||
type === 'preview' ? 'preview.webp'
|
||||
: 'image.webp'
|
||||
}?${query({
|
||||
url: _imageUrl,
|
||||
...(!noFallback ? { 'fallback': '1' } : {}),
|
||||
...(type ? { [type]: '1' } : {}),
|
||||
...(mustOrigin ? { origin: '1' } : {}),
|
||||
})}`;
|
||||
}
|
||||
|
||||
public getProxiedImageUrlNullable(imageUrl: string | null | undefined, type?: 'preview'): string | null {
|
||||
if (imageUrl == null) return null;
|
||||
return this.getProxiedImageUrl(imageUrl, type);
|
||||
}
|
||||
|
||||
public getStaticImageUrl(baseUrl: string): string {
|
||||
const u = baseUrl.startsWith('http') ? new URL(baseUrl) : new URL(baseUrl, this.url);
|
||||
|
||||
if (u.href.startsWith(`${this.url}/emoji/`)) {
|
||||
// もう既にemojiっぽそうだったらsearchParams付けるだけ
|
||||
u.searchParams.set('static', '1');
|
||||
return u.href;
|
||||
}
|
||||
|
||||
if (u.href.startsWith(this.serverMetadata.mediaProxy + '/')) {
|
||||
// もう既にproxyっぽそうだったらsearchParams付けるだけ
|
||||
u.searchParams.set('static', '1');
|
||||
return u.href;
|
||||
}
|
||||
|
||||
return `${this.serverMetadata.mediaProxy}/static.webp?${query({
|
||||
url: u.href,
|
||||
static: '1',
|
||||
})}`;
|
||||
}
|
||||
}
|
||||
153
packages/frontend-shared/js/scroll.ts
Normal file
153
packages/frontend-shared/js/scroll.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
type ScrollBehavior = 'auto' | 'smooth' | 'instant';
|
||||
|
||||
export function getScrollContainer(el: HTMLElement | null): HTMLElement | null {
|
||||
if (el == null || el.tagName === 'HTML') return null;
|
||||
const overflow = window.getComputedStyle(el).getPropertyValue('overflow-y');
|
||||
if (overflow === 'scroll' || overflow === 'auto') {
|
||||
return el;
|
||||
} else {
|
||||
return getScrollContainer(el.parentElement);
|
||||
}
|
||||
}
|
||||
|
||||
export function getStickyTop(el: HTMLElement, container: HTMLElement | null = null, top = 0) {
|
||||
if (!el.parentElement) return top;
|
||||
const data = el.dataset.stickyContainerHeaderHeight;
|
||||
const newTop = data ? Number(data) + top : top;
|
||||
if (el === container) return newTop;
|
||||
return getStickyTop(el.parentElement, container, newTop);
|
||||
}
|
||||
|
||||
export function getStickyBottom(el: HTMLElement, container: HTMLElement | null = null, bottom = 0) {
|
||||
if (!el.parentElement) return bottom;
|
||||
const data = el.dataset.stickyContainerFooterHeight;
|
||||
const newBottom = data ? Number(data) + bottom : bottom;
|
||||
if (el === container) return newBottom;
|
||||
return getStickyBottom(el.parentElement, container, newBottom);
|
||||
}
|
||||
|
||||
export function getScrollPosition(el: HTMLElement | null): number {
|
||||
const container = getScrollContainer(el);
|
||||
return container == null ? window.scrollY : container.scrollTop;
|
||||
}
|
||||
|
||||
export function onScrollTop(el: HTMLElement, cb: (topVisible: boolean) => unknown, tolerance = 1, once = false) {
|
||||
// とりあえず評価してみる
|
||||
const firstTopVisible = isTopVisible(el);
|
||||
if (el.isConnected && firstTopVisible) {
|
||||
cb(firstTopVisible);
|
||||
if (once) return null;
|
||||
}
|
||||
|
||||
const container = getScrollContainer(el) ?? window;
|
||||
|
||||
// 以下のケースにおいて、cbが何度も呼び出されてしまって具合が悪いので1回呼んだら以降は無視するようにする
|
||||
// - スクロールイベントは1回のスクロールで複数回発生することがある
|
||||
// - toleranceの範囲内に収まる程度の微量なスクロールが発生した
|
||||
let prevTopVisible = firstTopVisible;
|
||||
const onScroll = () => {
|
||||
if (!document.body.contains(el)) return;
|
||||
|
||||
const topVisible = isTopVisible(el, tolerance);
|
||||
if (topVisible !== prevTopVisible) {
|
||||
prevTopVisible = topVisible;
|
||||
cb(topVisible);
|
||||
if (once) removeListener();
|
||||
}
|
||||
};
|
||||
|
||||
function removeListener() { container.removeEventListener('scroll', onScroll); }
|
||||
|
||||
container.addEventListener('scroll', onScroll, { passive: true });
|
||||
return removeListener;
|
||||
}
|
||||
|
||||
export function onScrollBottom(el: HTMLElement, cb: () => unknown, tolerance = 1, once = false) {
|
||||
const container = getScrollContainer(el);
|
||||
|
||||
// とりあえず評価してみる
|
||||
if (el.isConnected && isBottomVisible(el, tolerance, container)) {
|
||||
cb();
|
||||
if (once) return null;
|
||||
}
|
||||
|
||||
const containerOrWindow = container ?? window;
|
||||
const onScroll = () => {
|
||||
if (!document.body.contains(el)) return;
|
||||
if (isBottomVisible(el, 1, container)) {
|
||||
cb();
|
||||
if (once) removeListener();
|
||||
}
|
||||
};
|
||||
|
||||
function removeListener() {
|
||||
containerOrWindow.removeEventListener('scroll', onScroll);
|
||||
}
|
||||
|
||||
containerOrWindow.addEventListener('scroll', onScroll, { passive: true });
|
||||
return removeListener;
|
||||
}
|
||||
|
||||
export function scroll(el: HTMLElement, options: ScrollToOptions | undefined) {
|
||||
const container = getScrollContainer(el);
|
||||
if (container == null) {
|
||||
window.scroll(options);
|
||||
} else {
|
||||
container.scroll(options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to Top
|
||||
* @param el Scroll container element
|
||||
* @param options Scroll options
|
||||
*/
|
||||
export function scrollToTop(el: HTMLElement, options: { behavior?: ScrollBehavior; } = {}) {
|
||||
scroll(el, { top: 0, ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to Bottom
|
||||
* @param el Content element
|
||||
* @param options Scroll options
|
||||
* @param container Scroll container element
|
||||
*/
|
||||
export function scrollToBottom(
|
||||
el: HTMLElement,
|
||||
options: ScrollToOptions = {},
|
||||
container = getScrollContainer(el),
|
||||
) {
|
||||
if (container) {
|
||||
container.scroll({ top: el.scrollHeight - container.clientHeight + getStickyTop(el, container) || 0, ...options });
|
||||
} else {
|
||||
window.scroll({
|
||||
top: (el.scrollHeight - window.innerHeight + getStickyTop(el, container) + (window.innerWidth <= 500 ? 96 : 0)) || 0,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function isTopVisible(el: HTMLElement, tolerance = 1): boolean {
|
||||
const scrollTop = getScrollPosition(el);
|
||||
if (_DEV_) console.log(scrollTop, tolerance, scrollTop <= tolerance);
|
||||
return scrollTop <= tolerance;
|
||||
}
|
||||
|
||||
export function isBottomVisible(el: HTMLElement, tolerance = 1, container = getScrollContainer(el)) {
|
||||
if (container) return el.scrollHeight <= container.clientHeight + Math.abs(container.scrollTop) + tolerance;
|
||||
return el.scrollHeight <= window.innerHeight + window.scrollY + tolerance;
|
||||
}
|
||||
|
||||
// https://ja.javascript.info/size-and-scroll-window#ref-932
|
||||
export function getBodyScrollHeight() {
|
||||
return Math.max(
|
||||
document.body.scrollHeight, document.documentElement.scrollHeight,
|
||||
document.body.offsetHeight, document.documentElement.offsetHeight,
|
||||
document.body.clientHeight, document.documentElement.clientHeight,
|
||||
);
|
||||
}
|
||||
28
packages/frontend-shared/js/url.ts
Normal file
28
packages/frontend-shared/js/url.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* objを検査して
|
||||
* 1. 配列に何も入っていない時はクエリを付けない
|
||||
* 2. プロパティがundefinedの時はクエリを付けない
|
||||
* (new URLSearchParams(obj)ではそこまで丁寧なことをしてくれない)
|
||||
*/
|
||||
export function query(obj: Record<string, string | number | boolean>): string {
|
||||
const params = Object.entries(obj)
|
||||
.filter(([, v]) => Array.isArray(v) ? v.length : v !== undefined) // eslint-disable-line @typescript-eslint/no-unnecessary-condition
|
||||
.reduce<Record<string, string | number | boolean>>((a, [k, v]) => (a[k] = v, a), {});
|
||||
|
||||
return Object.entries(params)
|
||||
.map((p) => `${p[0]}=${encodeURIComponent(p[1])}`)
|
||||
.join('&');
|
||||
}
|
||||
|
||||
export function appendQuery(url: string, queryString: string): string {
|
||||
return `${url}${/\?/.test(url) ? url.endsWith('?') ? '' : '&' : '?'}${queryString}`;
|
||||
}
|
||||
|
||||
export function extractDomain(url: string) {
|
||||
const match = url.match(/^(?:https?:)?(?:\/\/)?(?:[^@\n]+@)?([^:\/\n]+)/im);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
25
packages/frontend-shared/js/use-document-visibility.ts
Normal file
25
packages/frontend-shared/js/use-document-visibility.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
export function useDocumentVisibility(): Ref<DocumentVisibilityState> {
|
||||
const visibility = ref(document.visibilityState);
|
||||
|
||||
const onChange = (): void => {
|
||||
visibility.value = document.visibilityState;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('visibilitychange', onChange);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('visibilitychange', onChange);
|
||||
});
|
||||
|
||||
return visibility;
|
||||
}
|
||||
46
packages/frontend-shared/js/use-interval.ts
Normal file
46
packages/frontend-shared/js/use-interval.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { onActivated, onDeactivated, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
export function useInterval(fn: () => void, interval: number, options: {
|
||||
immediate: boolean;
|
||||
afterMounted: boolean;
|
||||
}): (() => void) | undefined {
|
||||
if (Number.isNaN(interval)) return;
|
||||
|
||||
let intervalId: number | null = null;
|
||||
|
||||
if (options.afterMounted) {
|
||||
onMounted(() => {
|
||||
if (options.immediate) fn();
|
||||
intervalId = window.setInterval(fn, interval);
|
||||
});
|
||||
} else {
|
||||
if (options.immediate) fn();
|
||||
intervalId = window.setInterval(fn, interval);
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
if (intervalId) window.clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
};
|
||||
|
||||
onActivated(() => {
|
||||
if (intervalId) return;
|
||||
if (options.immediate) fn();
|
||||
intervalId = window.setInterval(fn, interval);
|
||||
});
|
||||
|
||||
onDeactivated(() => {
|
||||
clear();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clear();
|
||||
});
|
||||
|
||||
return clear;
|
||||
}
|
||||
86
packages/frontend-shared/js/worker-multi-dispatch.ts
Normal file
86
packages/frontend-shared/js/worker-multi-dispatch.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
function defaultUseWorkerNumber(prev: number) {
|
||||
return prev + 1;
|
||||
}
|
||||
|
||||
type WorkerNumberGetter = (prev: number, totalWorkers: number) => number;
|
||||
|
||||
export class WorkerMultiDispatch<POST = unknown, RETURN = unknown> {
|
||||
private symbol = Symbol('WorkerMultiDispatch');
|
||||
private workers: Worker[] = [];
|
||||
private terminated = false;
|
||||
private prevWorkerNumber = 0;
|
||||
private getUseWorkerNumber: WorkerNumberGetter;
|
||||
private finalizationRegistry: FinalizationRegistry<symbol>;
|
||||
|
||||
constructor(workerConstructor: () => Worker, concurrency: number, getUseWorkerNumber = defaultUseWorkerNumber) {
|
||||
this.getUseWorkerNumber = getUseWorkerNumber;
|
||||
for (let i = 0; i < concurrency; i++) {
|
||||
this.workers.push(workerConstructor());
|
||||
}
|
||||
|
||||
this.finalizationRegistry = new FinalizationRegistry(() => {
|
||||
this.terminate();
|
||||
});
|
||||
this.finalizationRegistry.register(this, this.symbol);
|
||||
|
||||
if (_DEV_) console.log('WorkerMultiDispatch: Created', this);
|
||||
}
|
||||
|
||||
public postMessage(message: POST, options?: Transferable[] | StructuredSerializeOptions, useWorkerNumber: WorkerNumberGetter = this.getUseWorkerNumber) {
|
||||
let workerNumber = useWorkerNumber(this.prevWorkerNumber, this.workers.length);
|
||||
workerNumber = Math.abs(Math.round(workerNumber)) % this.workers.length;
|
||||
if (_DEV_) console.log('WorkerMultiDispatch: Posting message to worker', workerNumber, useWorkerNumber);
|
||||
this.prevWorkerNumber = workerNumber;
|
||||
|
||||
// 不毛だがunionをoverloadに突っ込めない
|
||||
// https://stackoverflow.com/questions/66507585/overload-signatures-union-types-and-no-overload-matches-this-call-error
|
||||
// https://github.com/microsoft/TypeScript/issues/14107
|
||||
if (Array.isArray(options)) {
|
||||
this.workers[workerNumber].postMessage(message, options);
|
||||
} else {
|
||||
this.workers[workerNumber].postMessage(message, options);
|
||||
}
|
||||
return workerNumber;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
public addListener(callback: (this: Worker, ev: MessageEvent<RETURN>) => any, options?: boolean | AddEventListenerOptions) {
|
||||
this.workers.forEach(worker => {
|
||||
worker.addEventListener('message', callback, options);
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
public removeListener(callback: (this: Worker, ev: MessageEvent<RETURN>) => any, options?: boolean | AddEventListenerOptions) {
|
||||
this.workers.forEach(worker => {
|
||||
worker.removeEventListener('message', callback, options);
|
||||
});
|
||||
}
|
||||
|
||||
public terminate() {
|
||||
this.terminated = true;
|
||||
if (_DEV_) console.log('WorkerMultiDispatch: Terminating', this);
|
||||
this.workers.forEach(worker => {
|
||||
worker.terminate();
|
||||
});
|
||||
this.workers = [];
|
||||
this.finalizationRegistry.unregister(this);
|
||||
}
|
||||
|
||||
public isTerminated() {
|
||||
return this.terminated;
|
||||
}
|
||||
|
||||
public getWorkers() {
|
||||
return this.workers;
|
||||
}
|
||||
|
||||
public getSymbol() {
|
||||
return this.symbol;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue