feat(frontend): ノート・ユーザータイムライン埋め込み (#13929)

* fix

* navhookをbootに移動

* サーバーサイドのbootも分けるように

* 埋め込みページかどうかの判定は最初の一回だけに

* tooltipは出せるように

* fix design

* 埋め込み独自のtooltipを削除

* ロジックの分岐が多かったMkNoteDetailedを分離

* fix indent

* プレビュー用iframeにフォーカスが当たるのを修正

* popupの制御を出す側で行うように

* パラメータが逆になっていたのを修正

* Update MkEmbedCodeGenDialog.vue

* fix

* eliminate misskey-js lint warns

* fix

* add appropriate attributes to embed html

* enhance: サーバーサイドのembed系をさらに分離

* enhance: embed routerを分離(route定義をboot時に変更できるようにする改修を含む)

* type

* lint

* fix indent

* server-side styleを完全に分離

* Revert "refactor: 画面サイズのしきい値をconstにまとめる"

This reverts commit 05ca36f400.

* fix

* revert all changes in base.pug

* embedドメインをまとめた

* embedドメインをまとめた

* prevent calling contextmenu in embed page by stopping at the caller

* fix import

* fix import

* improve directory structure

* fix import

* register timeline ui as a container

* wa-

* rename

* wa-

* Update EmMediaList.vue

* Update EmMediaList.vue

* Update EmMediaList.vue

* Update EmMediaImage.vue

* Update EmNote.vue

* revert mkmedialist changes

* 戻し漏れ

* wip

* tweak embed media ui

* revert original media components

* Update boot.embed.js

* rename

* wip

* Update MkNote.vue

* wip

* Update MkSubNoteContent.vue

* Update EmNote.vue

* Update packages/frontend/src/router/definition.ts

* Revert "Update packages/frontend/src/router/definition.ts"

This reverts commit 937ae44521.

* refactor EmMediaImage

* fix import

* remove unused imports

* Update router.ts

* wip

* Update boot.ts

* wip

* wip

* wip

* wip

* Update EmNote.vue

* Update EmNote.vue

* Create EmA.vue

* Create EmAvatar.vue

* Update EmAvatar.vue

* wip

* wip

* wip

* Create EmImgWithBlurhash.vue

* Update EmImgWithBlurhash.vue

* Create EmPagination.vue

* wip

* Update boot.ts

* wip

* wip

* wi@p

* wip

* wip

* wiop

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* Update boot.ts

* wip

* Update MkMisskeyFlavoredMarkdown.ts

* wip

* wip

* wip

* wip

* wip

* Update post-message.ts

* wip

* Update EmNoteDetailed.vue

* Update EmNoteDetailed.vue

* Create instance.ts

* Update EmNoteDetailed.vue

* wip

* Update EmNoteDetailed.vue

* wip

* wip

* wip

* Update pnpm-lock.yaml

* wip

* wip

* wp

* wip

* Update ClientServerService.ts

* wip

* Update boot.ts

* Update vite.config.local-dev.ts

* Update vite.config.ts

* Create index.html

* wa-

* wip

* Update boot.ts

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* Create EmLink.vue

* Create EmMention.vue

* Update EmMfm.ts

* wip

* wip

* wip

* wip

* Update vite.config.ts

* Update boot.ts

* Update EmA.vue

* うぃp

* wip

* wip

* Create EmError.vue

* wip

* Update MkEmbedCodeGenDialog.vue

* Update EmNote.vue

* wip

* wip

* Update user-timeline.vue

* Update check-spdx-license-id.yml

* wip

* wip

* style(frontend-shared): lint fixes on build.js

* fix(frontend-shared): include `*.{js,json}` files in js-built

* wip

* use alias

* refactor

* refactor

* Update scroll.ts

* refactor

* refactor

* refactor

* wip

* wip

* wip

* wip

* Update roles.vue

* Update branding.vue

* wip

* wip

* wip

* Update page.vue

* wip

* fix import

* add missing css variables

* 絵文字をtwemojiに変更

クライアントデフォルトにあわせるため

* force empoll readonly

* fix compiler error

* fix broken imports

* tweak button style

* run api extractor

* fix storybook theme preloads

* fix storybook instance imports

* Update preview.ts

* Update preview.ts

* Update preview.ts

* Revert "Update preview.ts"

This reverts commit 12bab1c6fb.

* Revert "Update preview.ts"

This reverts commit 5c0ce01dbd.

* Revert "Update preview.ts"

This reverts commit f4863524d7.

* Revert "fix storybook instance imports"

This reverts commit ed8eabb246.

* Revert "wip"

This reverts commit d3c1926519.

* Revert "Update page.vue"

This reverts commit 27c7900b0c.

* Revert "Update branding.vue"

This reverts commit c08ccb65ba.

* Revert "Update roles.vue"

This reverts commit 1488b67066.

* Revert "wip"

This reverts commit aab1c76981.

* refactor: use common media proxy

* fix imports

* fix

* fix: MediaProxyの初期化を保証する(storybook対策?)

* enhance(frontend-embed): improve embedParams provide

* fix(backend): MK_DEV_PREFER=backendのときにembed viteが読み込めないのを修正

* fix

* embed-pageを共通化

* fix import

* fix import

* fix import

* const.jsを共通化

(たぶんrevertしすぎた)

* fix type error

* fix duplicated import

* fix lint

* fix

* コメントとして残す

* sharedとembedをlint対象にする

* lint

* attempt to fix eslint (frontend-shared)

* lint fixes

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
Co-authored-by: zyoshoka <107108195+zyoshoka@users.noreply.github.com>
This commit is contained in:
かっこかり
2024-09-09 20:57:36 +09:00
committed by GitHub
parent 0d0cd738f8
commit 2cbe1d1210
236 changed files with 9470 additions and 454 deletions

View File

@@ -4,13 +4,13 @@
*/
import { utils, values } from '@syuilo/aiscript';
import * as Misskey from 'misskey-js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { $i } from '@/account.js';
import { miLocalStorage } from '@/local-storage.js';
import { customEmojis } from '@/custom-emojis.js';
import { url, lang } from '@/config.js';
import { nyaize } from '@/scripts/nyaize.js';
export function aiScriptReadline(q: string): Promise<string> {
return new Promise(ok => {
@@ -87,7 +87,7 @@ export function createAiScriptEnv(opts) {
}),
'Mk:nyaize': values.FN_NATIVE(([text]) => {
utils.assertString(text);
return values.STR(nyaize(text.value));
return values.STR(Misskey.nyaize(text.value));
}),
};
}

View File

@@ -4,7 +4,7 @@
*/
import * as Misskey from 'misskey-js';
import { UnicodeEmojiDef } from './emojilist.js';
import { UnicodeEmojiDef } from '@@/js/emojilist.js';
export function checkReactionPermissions(me: Misskey.entities.MeDetailed, note: Misskey.entities.Note, emoji: Misskey.entities.EmojiSimple | UnicodeEmojiDef | string): boolean {
if (typeof emoji === 'string') return true; // UnicodeEmojiDefにも無い絵文字であれば文字列で来る。Unicode絵文字であることには変わりないので常にリアクション可能とする;

View File

@@ -7,13 +7,13 @@ import { getHighlighterCore, loadWasm } from 'shiki/core';
import darkPlus from 'shiki/themes/dark-plus.mjs';
import { bundledThemesInfo } from 'shiki/themes';
import { bundledLanguagesInfo } from 'shiki/langs';
import lightTheme from '@@/themes/_light.json5';
import darkTheme from '@@/themes/_dark.json5';
import { unique } from './array.js';
import { deepClone } from './clone.js';
import { deepMerge } from './merge.js';
import type { HighlighterCore, LanguageRegistration, ThemeRegistration, ThemeRegistrationRaw } from 'shiki/core';
import { ColdDeviceStorage } from '@/store.js';
import lightTheme from '@/themes/_light.json5';
import darkTheme from '@/themes/_dark.json5';
let _highlighter: HighlighterCore | null = null;

View File

@@ -1,25 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
const twemojiSvgBase = '/twemoji';
const fluentEmojiPngBase = '/fluent-emoji';
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 && x.length);
const fileName = codes.map(x => x!.padStart(4, '0')).join('-');
return `${fluentEmojiPngBase}/${fileName}.png`;
}

View File

@@ -1,73 +0,0 @@
/*
* 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]],
}));
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[];
}

View File

@@ -1,14 +0,0 @@
/*
* 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;
}

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { getScrollPosition, getScrollContainer, getStickyBottom, getStickyTop } from '@/scripts/scroll.js';
import { getScrollPosition, getScrollContainer, getStickyBottom, getStickyTop } from '@@/js/scroll.js';
import { getElementOrNull, getNodeOrNull } from '@/scripts/get-dom-node-or-null.js';
type MaybeHTMLElement = EventTarget | Node | Element | HTMLElement;

View File

@@ -0,0 +1,87 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineAsyncComponent } from 'vue';
import { v4 as uuid } from 'uuid';
import type { EmbedParams, EmbeddableEntity } from '@@/js/embed-page.js';
import { url } from '@/config.js';
import * as os from '@/os.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { defaultEmbedParams, embedRouteWithScrollbar } from '@@/js/embed-page.js';
const MOBILE_THRESHOLD = 500;
/**
* パラメータを正規化する(埋め込みコード作成用)
* @param params パラメータ
* @returns 正規化されたパラメータ
*/
export function normalizeEmbedParams(params: EmbedParams): Record<string, string> {
// paramsのvalueをすべてstringに変換。undefinedやnullはプロパティごと消す
const normalizedParams: Record<string, string> = {};
for (const key in params) {
// デフォルトの値と同じならparamsに含めない
if (params[key] == null || params[key] === defaultEmbedParams[key]) {
continue;
}
switch (typeof params[key]) {
case 'number':
normalizedParams[key] = params[key].toString();
break;
case 'boolean':
normalizedParams[key] = params[key] ? 'true' : 'false';
break;
default:
normalizedParams[key] = params[key];
break;
}
}
return normalizedParams;
}
/**
* 埋め込みコードを生成iframe IDの発番もやる
*/
export function getEmbedCode(path: string, params?: EmbedParams): string {
const iframeId = 'v1_' + uuid(); // 将来embed.jsのバージョンが上がったとき用にv1_を付けておく
let paramString = '';
if (params) {
const searchParams = new URLSearchParams(normalizeEmbedParams(params));
paramString = searchParams.toString() === '' ? '' : '?' + searchParams.toString();
}
const iframeCode = [
`<iframe src="${url + path + paramString}" data-misskey-embed-id="${iframeId}" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" style="border: none; width: 100%; max-width: 500px; height: 300px; color-scheme: light dark;"></iframe>`,
`<script defer src="${url}/embed.js"></script>`,
];
return iframeCode.join('\n');
}
/**
* 埋め込みコードを生成してコピーする(カスタマイズ機能つき)
*
* カスタマイズ機能がいらない場合(事前にパラメータを指定する場合)は getEmbedCode を直接使ってください
*/
export function genEmbedCode(entity: EmbeddableEntity, id: string, params?: EmbedParams) {
const _params = { ...params };
if (embedRouteWithScrollbar.includes(entity) && _params.maxHeight == null) {
_params.maxHeight = 700;
}
// PCじゃない場合はコードカスタマイズ画面を出さずにそのままコピー
if (window.innerWidth < MOBILE_THRESHOLD) {
copyToClipboard(getEmbedCode(`/embed/${entity}/${id}`, _params));
os.success();
} else {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkEmbedCodeGenDialog.vue')), {
entity,
id,
params: _params,
}, {
closed: () => dispose(),
});
}
}

View File

@@ -21,6 +21,7 @@ import { MenuItem } from '@/types/menu.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { isSupportShare } from '@/scripts/navigator.js';
import { getAppearNote } from '@/scripts/get-appear-note.js';
import { genEmbedCode } from '@/scripts/get-embed-code.js';
export async function getNoteClipMenu(props: {
note: Misskey.entities.Note;
@@ -156,6 +157,19 @@ export function getCopyNoteLinkMenu(note: Misskey.entities.Note, text: string):
};
}
function getNoteEmbedCodeMenu(note: Misskey.entities.Note, text: string): MenuItem | undefined {
if (note.url != null || note.uri != null) return undefined;
if (['specified', 'followers'].includes(note.visibility)) return undefined;
return {
icon: 'ti ti-code',
text,
action: (): void => {
genEmbedCode('notes', note.id);
},
};
}
export function getNoteMenu(props: {
note: Misskey.entities.Note;
translation: Ref<Misskey.entities.NotesTranslateResponse | null>;
@@ -310,7 +324,7 @@ export function getNoteMenu(props: {
action: () => {
window.open(appearNote.url ?? appearNote.uri, '_blank', 'noopener');
},
} : undefined,
} : getNoteEmbedCodeMenu(appearNote, i18n.ts.genEmbedCode),
...(isSupportShare() ? [{
icon: 'ti ti-share',
text: i18n.ts.share,
@@ -443,14 +457,14 @@ export function getNoteMenu(props: {
icon: 'ti ti-copy',
text: i18n.ts.copyContent,
action: copyContent,
}, getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink)
, (appearNote.url || appearNote.uri) ? {
}, getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink),
(appearNote.url || appearNote.uri) ? {
icon: 'ti ti-external-link',
text: i18n.ts.showOnRemote,
action: () => {
window.open(appearNote.url ?? appearNote.uri, '_blank', 'noopener');
},
} : undefined]
} : getNoteEmbedCodeMenu(appearNote, i18n.ts.genEmbedCode)]
.filter(x => x !== undefined);
}

View File

@@ -17,6 +17,7 @@ import { notesSearchAvailable, canSearchNonLocalNotes } from '@/scripts/check-pe
import { IRouter } from '@/nirax.js';
import { antennasCache, rolesCache, userListsCache } from '@/cache.js';
import { mainRouter } from '@/router/main.js';
import { genEmbedCode } from '@/scripts/get-embed-code.js';
import { MenuItem } from '@/types/menu.js';
export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter = mainRouter) {
@@ -179,7 +180,17 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter
if (user.url == null) return;
window.open(user.url, '_blank', 'noopener');
},
}] : []), {
}] : [{
icon: 'ti ti-code',
text: i18n.ts.genEmbedCode,
type: 'parent' as const,
children: [{
text: i18n.ts.noteOfThisUser,
action: () => {
genEmbedCode('user-timeline', user.id);
},
}], // TODO: ユーザーカードの埋め込みなど
}]), {
icon: 'ti ti-share',
text: i18n.ts.copyProfileUrl,
action: () => {

View File

@@ -1,245 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ILocale, ParameterizedString } from '../../../../locales/index.js';
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>;
constructor(public locale: T) {
//#region BIND
this.t = this.t.bind(this);
//#endregion
}
public get ts(): T {
if (_DEV_) {
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 (_DEV_) {
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) => {
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[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[k] = (arg) => {
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[k];
if (_DEV_) {
if (typeof str === 'undefined') {
console.error(`Unexpected locale key: ${key}`);
return key;
}
}
}
if (args) {
if (_DEV_) {
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 (_DEV_) {
if (!(str as string).includes(search)) {
console.error(`Unexpected locale parameter: ${k} at ${key}`);
}
}
str = (str as string).replace(search, v.toString());
}
}
return str;
}
}

View File

@@ -10,10 +10,11 @@ import {
set as iset,
del as idel,
} from 'idb-keyval';
import { miLocalStorage } from '@/local-storage.js';
const fallbackName = (key: string) => `idbfallback::${key}`;
const PREFIX = 'idbfallback::';
let idbAvailable = typeof window !== 'undefined' ? !!(window.indexedDB && window.indexedDB.open) : true;
let idbAvailable = typeof window !== 'undefined' ? !!(window.indexedDB && typeof window.indexedDB.open === 'function') : true;
// iframe.contentWindow.indexedDB.deleteDatabase() がchromeのバグで使用できないため、indexedDBを無効化している。
// バグが治って再度有効化するのであれば、cypressのコマンド内のコメントアウトを外すこと
@@ -38,15 +39,15 @@ if (idbAvailable) {
export async function get(key: string) {
if (idbAvailable) return iget(key);
return JSON.parse(window.localStorage.getItem(fallbackName(key)));
return miLocalStorage.getItemAsJson(`${PREFIX}${key}`);
}
export async function set(key: string, val: any) {
if (idbAvailable) return iset(key, val);
return window.localStorage.setItem(fallbackName(key), JSON.stringify(val));
return miLocalStorage.setItemAsJson(`${PREFIX}${key}`, val);
}
export async function del(key: string) {
if (idbAvailable) return idel(key);
return window.localStorage.removeItem(fallbackName(key));
return miLocalStorage.removeItem(`${PREFIX}${key}`);
}

View 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;
}

View File

@@ -3,51 +3,32 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { query } from '@/scripts/url.js';
import { MediaProxy } from '@@/js/media-proxy.js';
import { url } from '@/config.js';
import { instance } from '@/instance.js';
export function getProxiedImageUrl(imageUrl: string, type?: 'preview' | 'emoji' | 'avatar', mustOrigin = false, noFallback = false): string {
const localProxy = `${url}/proxy`;
let _mediaProxy: MediaProxy | null = null;
if (imageUrl.startsWith(instance.mediaProxy + '/') || imageUrl.startsWith('/proxy/') || imageUrl.startsWith(localProxy + '/')) {
// もう既にproxyっぽそうだったらurlを取り出す
imageUrl = (new URL(imageUrl)).searchParams.get('url') ?? imageUrl;
export function getProxiedImageUrl(...args: Parameters<MediaProxy['getProxiedImageUrl']>): string {
if (_mediaProxy == null) {
_mediaProxy = new MediaProxy(instance, url);
}
return `${mustOrigin ? localProxy : instance.mediaProxy}/${
type === 'preview' ? 'preview.webp'
: 'image.webp'
}?${query({
url: imageUrl,
...(!noFallback ? { 'fallback': '1' } : {}),
...(type ? { [type]: '1' } : {}),
...(mustOrigin ? { origin: '1' } : {}),
})}`;
return _mediaProxy.getProxiedImageUrl(...args);
}
export function getProxiedImageUrlNullable(imageUrl: string | null | undefined, type?: 'preview'): string | null {
if (imageUrl == null) return null;
return getProxiedImageUrl(imageUrl, type);
}
export function getStaticImageUrl(baseUrl: string): string {
const u = baseUrl.startsWith('http') ? new URL(baseUrl) : new URL(baseUrl, url);
if (u.href.startsWith(`${url}/emoji/`)) {
// もう既にemojiっぽそうだったらsearchParams付けるだけ
u.searchParams.set('static', '1');
return u.href;
export function getProxiedImageUrlNullable(...args: Parameters<MediaProxy['getProxiedImageUrlNullable']>): string | null {
if (_mediaProxy == null) {
_mediaProxy = new MediaProxy(instance, url);
}
if (u.href.startsWith(instance.mediaProxy + '/')) {
// もう既にproxyっぽそうだったらsearchParams付けるだけ
u.searchParams.set('static', '1');
return u.href;
return _mediaProxy.getProxiedImageUrlNullable(...args);
}
export function getStaticImageUrl(...args: Parameters<MediaProxy['getStaticImageUrl']>): string {
if (_mediaProxy == null) {
_mediaProxy = new MediaProxy(instance, url);
}
return `${instance.mediaProxy}/static.webp?${query({
url: u.href,
static: '1',
})}`;
return _mediaProxy.getStaticImageUrl(...args);
}

View File

@@ -6,7 +6,7 @@
import { Ref, nextTick } from 'vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { MFM_TAGS } from '@/const.js';
import { MFM_TAGS } from '@@/js/const.js';
import type { MenuItem } from '@/types/menu.js';
/**

View File

@@ -1,27 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
const enRegex1 = /(?<=n)a/gi;
const enRegex2 = /(?<=morn)ing/gi;
const enRegex3 = /(?<=every)one/gi;
const koRegex1 = /[나-낳]/g;
const koRegex2 = /(다$)|(다(?=\.))|(다(?= ))|(다(?=!))|(다(?=\?))/gm;
const koRegex3 = /(야(?=\?))|(야$)|(야(?= ))/gm;
export function nyaize(text: string): string {
return text
// ja-JP
.replaceAll('な', 'にゃ').replaceAll('ナ', 'ニャ').replaceAll('ナ', 'ニャ')
// en-US
.replace(enRegex1, x => x === 'A' ? 'YA' : 'ya')
.replace(enRegex2, x => x === 'ING' ? 'YAN' : 'yan')
.replace(enRegex3, x => x === 'ONE' ? 'NYAN' : 'nyan')
// ko-KR
.replace(koRegex1, match => String.fromCharCode(
match.charCodeAt(0)! + '냐'.charCodeAt(0) - '나'.charCodeAt(0),
))
.replace(koRegex2, '다냥')
.replace(koRegex3, '냥');
}

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { appendQuery } from './url.js';
import { appendQuery } from '@@/js/url.js';
import * as config from '@/config.js';
export function popout(path: string, w?: HTMLElement) {

View File

@@ -18,7 +18,7 @@ export type MiPostMessageEvent = {
* 親フレームにイベントを送信
*/
export function postMessageToParentWindow(type: PostMessageEventType, payload?: any): void {
window.postMessage({
window.parent.postMessage({
type,
payload,
}, '*');

View File

@@ -1,11 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export function safeParseFloat(str: unknown): number | null {
if (typeof str !== 'string' || str === '') return null;
const num = parseFloat(str);
if (isNaN(num)) return null;
return num;
}

View File

@@ -1,12 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export function safeURIDecode(str: string): string {
try {
return decodeURIComponent(str);
} catch {
return str;
}
}

View File

@@ -1,144 +0,0 @@
/*
* 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: () => unknown, tolerance = 1, once = false) {
// とりあえず評価してみる
if (el.isConnected && isTopVisible(el)) {
cb();
if (once) return null;
}
const container = getScrollContainer(el) ?? window;
const onScroll = ev => {
if (!document.body.contains(el)) return;
if (isTopVisible(el, tolerance)) {
cb();
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 = ev => {
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);
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,
);
}

View File

@@ -0,0 +1,81 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { EventEmitter } from 'eventemitter3';
import * as Misskey from 'misskey-js';
import type { Channels, StreamEvents, IStream, IChannelConnection } from 'misskey-js';
type AnyOf<T extends Record<any, any>> = T[keyof T];
type OmitFirst<T extends any[]> = T extends [any, ...infer R] ? R : never;
/**
* Websocket無効化時に使うStreamのモックなにもしない
*/
export class StreamMock extends EventEmitter<StreamEvents> implements IStream {
public readonly state = 'initializing';
constructor(...args: ConstructorParameters<typeof Misskey.Stream>) {
super();
// do nothing
}
public useChannel<C extends keyof Channels>(channel: C, params?: Channels[C]['params'], name?: string): ChannelConnectionMock<Channels[C]> {
return new ChannelConnectionMock(this, channel, name);
}
public removeSharedConnection(connection: any): void {
// do nothing
}
public removeSharedConnectionPool(pool: any): void {
// do nothing
}
public disconnectToChannel(): void {
// do nothing
}
public send(typeOrPayload: string): void
public send(typeOrPayload: string, payload: any): void
public send(typeOrPayload: Record<string, any> | any[]): void
public send(typeOrPayload: string | Record<string, any> | any[], payload?: any): void {
// do nothing
}
public ping(): void {
// do nothing
}
public heartbeat(): void {
// do nothing
}
public close(): void {
// do nothing
}
}
class ChannelConnectionMock<Channel extends AnyOf<Channels> = any> extends EventEmitter<Channel['events']> implements IChannelConnection<Channel> {
public id = '';
public name?: string; // for debug
public inCount = 0; // for debug
public outCount = 0; // for debug
public channel: string;
constructor(stream: IStream, ...args: OmitFirst<ConstructorParameters<typeof Misskey.ChannelConnection<Channel>>>) {
super();
this.channel = args[0];
this.name = args[1];
}
public send<T extends keyof Channel['receives']>(type: T, body: Channel['receives'][T]): void {
// do nothing
}
public dispose(): void {
// do nothing
}
}

View File

@@ -5,11 +5,11 @@
import { ref } from 'vue';
import tinycolor from 'tinycolor2';
import lightTheme from '@@/themes/_light.json5';
import darkTheme from '@@/themes/_dark.json5';
import { deepClone } from './clone.js';
import type { BundledTheme } from 'shiki/themes';
import { globalEvents } from '@/events.js';
import lightTheme from '@/themes/_light.json5';
import darkTheme from '@/themes/_dark.json5';
import { miLocalStorage } from '@/local-storage.js';
export type Theme = {

View File

@@ -1,28 +0,0 @@
/*
* 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, any>): string {
const params = Object.entries(obj)
.filter(([, v]) => Array.isArray(v) ? v.length : v !== undefined)
.reduce((a, [k, v]) => (a[k] = v, a), {} as Record<string, any>);
return Object.entries(params)
.map((p) => `${p[0]}=${encodeURIComponent(p[1])}`)
.join('&');
}
export function appendQuery(url: string, query: string): string {
return `${url}${/\?/.test(url) ? url.endsWith('?') ? '' : '&' : '?'}${query}`;
}
export function extractDomain(url: string) {
const match = url.match(/^(?:https?:)?(?:\/\/)?(?:[^@\n]+@)?([^:\/\n]+)/im);
return match ? match[1] : null;
}

View File

@@ -1,24 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { onMounted, onUnmounted, ref, 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;
}

View File

@@ -1,46 +0,0 @@
/*
* 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;
}