diff --git a/.github/workflows/check-spdx-license-id.yml b/.github/workflows/check-spdx-license-id.yml index 6cd8bf60d5204d737333e01f4cf129052c4db47e..2579beb53a8ef6213e5495121f886d4e5c1b7d82 100644 --- a/.github/workflows/check-spdx-license-id.yml +++ b/.github/workflows/check-spdx-license-id.yml @@ -48,12 +48,14 @@ jobs: "packages/backend/migration" "packages/backend/src" "packages/backend/test" + "packages/frontend-shared/src" "packages/frontend/.storybook" "packages/frontend/@types" "packages/frontend/lib" "packages/frontend/public" "packages/frontend/src" "packages/frontend/test" + "packages/frontend-embed/src" "packages/misskey-bubble-game/src" "packages/misskey-reversi/src" "packages/sw/src" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 222a14d28df7b221375f7fab0e057c2844dbe4d0..11903e3ec2480d65f0357f5eb183bd16debaf64f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,6 +8,8 @@ on: paths: - packages/backend/** - packages/frontend/** + - packages/frontend-shared/** + - packages/frontend-embed/** - packages/sw/** - packages/misskey-js/** - packages/shared/eslint.config.js @@ -16,6 +18,8 @@ on: paths: - packages/backend/** - packages/frontend/** + - packages/frontend-shared/** + - packages/frontend-embed/** - packages/sw/** - packages/misskey-js/** - packages/shared/eslint.config.js @@ -45,6 +49,8 @@ jobs: workspace: - backend - frontend + - frontend-shared + - frontend-embed - sw - misskey-js env: diff --git a/CHANGELOG.md b/CHANGELOG.md index 398134436babb27cae64024f10400f44c6fa6616..16c6eb674d3d814649149d14866d02e9426a6879 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - ### Client +- Feat: ノートå˜ä½“・ユーザーã®ãƒŽãƒ¼ãƒˆãƒ»ã‚¯ãƒªãƒƒãƒ—ã®ãƒŽãƒ¼ãƒˆã®åŸ‹ã‚è¾¼ã¿æ©Ÿèƒ½ + - 埋ã‚è¾¼ã¿ã‚³ãƒ¼ãƒ‰ã‚„ウェブサイトã¸ã®å®Ÿè£…方法ã®è©³ç´°ã¯Misskey Hubã«æŽ²è¼‰äºˆå®šã§ã™ - サイズ制é™ã‚’超éŽã™ã‚‹ãƒ•ã‚¡ã‚¤ãƒ«ã‚’アップãƒãƒ¼ãƒ‰ã—よã†ã¨ã—ãŸéš›ã«ã‚¨ãƒ©ãƒ¼ã‚’出ã™ã‚ˆã†ã« - Enhance: アイコンデコレーション管ç†ç”»é¢ã«ãƒ—ãƒ¬ãƒ“ãƒ¥ãƒ¼ã‚’è¿½åŠ - Fix: サーãƒãƒ¼ãƒ¡ãƒˆãƒªã‚¯ã‚¹ãŒ2ã¤ä»¥ä¸Šã‚ã‚‹ã¨ãƒªãƒãƒ¼ãƒ‰ç›´å¾Œã®è¡¨ç¤ºãŒãŠã‹ã—ããªã‚‹å•é¡Œã‚’ä¿®æ£ diff --git a/Dockerfile b/Dockerfile index e247bbcd775f42ec540fef0a5322e503f9560d06..e21b2a31fcff14ed07f2c247ea647e682cc09102 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,9 @@ WORKDIR /misskey COPY --link ["pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "./"] COPY --link ["scripts", "./scripts"] COPY --link ["packages/backend/package.json", "./packages/backend/"] +COPY --link ["packages/frontend-shared/package.json", "./packages/frontend-shared/"] COPY --link ["packages/frontend/package.json", "./packages/frontend/"] +COPY --link ["packages/frontend-embed/package.json", "./packages/frontend-embed/"] COPY --link ["packages/sw/package.json", "./packages/sw/"] COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"] COPY --link ["packages/misskey-reversi/package.json", "./packages/misskey-reversi/"] diff --git a/locales/index.d.ts b/locales/index.d.ts index 9fd3441ab1c08f2272b213fdb7dc62fa07db11c0..fecc5703950debf0fb79acc0bea567fd8889f213 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5068,6 +5068,18 @@ export interface Locale extends ILocale { * 作æˆã—ãŸã‚¢ãƒ³ãƒ†ãƒŠ */ "createdAntennas": string; + /** + * {x}ã‹ã‚‰ + */ + "fromX": ParameterizedString<"x">; + /** + * 埋ã‚è¾¼ã¿ã‚³ãƒ¼ãƒ‰ã‚’ç”Ÿæˆ + */ + "genEmbedCode": string; + /** + * ã“ã®ãƒ¦ãƒ¼ã‚¶ãƒ¼ã®ãƒŽãƒ¼ãƒˆä¸€è¦§ + */ + "noteOfThisUser": string; /** * ã“れ以上ã“ã®ã‚¯ãƒªãƒƒãƒ—ã«ãƒŽãƒ¼ãƒˆã‚’è¿½åŠ ã§ãã¾ã›ã‚“。 */ @@ -10196,6 +10208,60 @@ export interface Locale extends ILocale { */ "native": string; }; + "_embedCodeGen": { + /** + * 埋ã‚è¾¼ã¿ã‚³ãƒ¼ãƒ‰ã‚’カスタマイズ + */ + "title": string; + /** + * ヘッダーを表示 + */ + "header": string; + /** + * 自動ã§ç¶šãã‚’èªã¿è¾¼ã‚€ï¼ˆéžæŽ¨å¥¨ï¼‰ + */ + "autoload": string; + /** + * 高ã•ã®æœ€å¤§å€¤ + */ + "maxHeight": string; + /** + * 0ã§æœ€å¤§å€¤ã®è¨å®šãŒç„¡åŠ¹ã«ãªã‚Šã¾ã™ã€‚ウィジェットãŒç¸¦ã«ä¼¸ã³ç¶šã‘ã‚‹ã®ã‚’防ããŸã‚ã«ã€ä½•ã‚‰ã‹ã®å€¤ã«æŒ‡å®šã—ã¦ãã ã•ã„。 + */ + "maxHeightDescription": string; + /** + * 高ã•ã®æœ€å¤§å€¤åˆ¶é™ãŒç„¡åŠ¹ï¼ˆ0)ã«ãªã£ã¦ã„ã¾ã™ã€‚ã“ã‚ŒãŒæ„図ã—ãŸå¤‰æ›´ã§ã¯ãªã„å ´åˆã¯ã€é«˜ã•ã®æœ€å¤§å€¤ã‚’何らã‹ã®å€¤ã«è¨å®šã—ã¦ãã ã•ã„。 + */ + "maxHeightWarn": string; + /** + * プレビュー画é¢ã§è¡¨ç¤ºå¯èƒ½ãªç¯„囲を超ãˆãŸãŸã‚ã€å®Ÿéš›ã«åŸ‹ã‚込んã éš›ã¨ã¯è¡¨ç¤ºãŒç•°ãªã‚Šã¾ã™ã€‚ + */ + "previewIsNotActual": string; + /** + * 角丸ã«ã™ã‚‹ + */ + "rounded": string; + /** + * å¤–æž ã«æž ç·šã‚’ã¤ã‘ã‚‹ + */ + "border": string; + /** + * プレビューã«åæ˜ + */ + "applyToPreview": string; + /** + * 埋ã‚è¾¼ã¿ã‚³ãƒ¼ãƒ‰ã‚’ä½œæˆ + */ + "generateCode": string; + /** + * コードãŒç”Ÿæˆã•ã‚Œã¾ã—㟠+ */ + "codeGenerated": string; + /** + * 生æˆã•ã‚ŒãŸã‚³ãƒ¼ãƒ‰ã‚’ウェブサイトã«è²¼ã‚Šä»˜ã‘ã¦ã”利用ãã ã•ã„。 + */ + "codeGeneratedDescription": string; + }; } declare const locales: { [lang: string]: Locale; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 587b67d98765c564732632cb0e364dd87e6546ca..a1210bad29481f871f0032c90c0dd74eefae8be9 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1263,6 +1263,9 @@ confirmWhenRevealingSensitiveMedia: "センシティブãªãƒ¡ãƒ‡ã‚£ã‚¢ã‚’表示 sensitiveMediaRevealConfirm: "センシティブãªãƒ¡ãƒ‡ã‚£ã‚¢ã§ã™ã€‚表示ã—ã¾ã™ã‹ï¼Ÿ" createdLists: "作æˆã—ãŸãƒªã‚¹ãƒˆ" createdAntennas: "作æˆã—ãŸã‚¢ãƒ³ãƒ†ãƒŠ" +fromX: "{x}ã‹ã‚‰" +genEmbedCode: "埋ã‚è¾¼ã¿ã‚³ãƒ¼ãƒ‰ã‚’生æˆ" +noteOfThisUser: "ã“ã®ãƒ¦ãƒ¼ã‚¶ãƒ¼ã®ãƒŽãƒ¼ãƒˆä¸€è¦§" clipNoteLimitExceeded: "ã“れ以上ã“ã®ã‚¯ãƒªãƒƒãƒ—ã«ãƒŽãƒ¼ãƒˆã‚’è¿½åŠ ã§ãã¾ã›ã‚“。" _delivery: @@ -2718,3 +2721,18 @@ _contextMenu: app: "アプリケーション" appWithShift: "Shiftã‚ーã§ã‚¢ãƒ—リケーション" native: "ブラウザã®UI" + +_embedCodeGen: + title: "埋ã‚è¾¼ã¿ã‚³ãƒ¼ãƒ‰ã‚’カスタマイズ" + header: "ヘッダーを表示" + autoload: "自動ã§ç¶šãã‚’èªã¿è¾¼ã‚€ï¼ˆéžæŽ¨å¥¨ï¼‰" + maxHeight: "高ã•ã®æœ€å¤§å€¤" + maxHeightDescription: "0ã§æœ€å¤§å€¤ã®è¨å®šãŒç„¡åŠ¹ã«ãªã‚Šã¾ã™ã€‚ウィジェットãŒç¸¦ã«ä¼¸ã³ç¶šã‘ã‚‹ã®ã‚’防ããŸã‚ã«ã€ä½•ã‚‰ã‹ã®å€¤ã«æŒ‡å®šã—ã¦ãã ã•ã„。" + maxHeightWarn: "高ã•ã®æœ€å¤§å€¤åˆ¶é™ãŒç„¡åŠ¹ï¼ˆ0)ã«ãªã£ã¦ã„ã¾ã™ã€‚ã“ã‚ŒãŒæ„図ã—ãŸå¤‰æ›´ã§ã¯ãªã„å ´åˆã¯ã€é«˜ã•ã®æœ€å¤§å€¤ã‚’何らã‹ã®å€¤ã«è¨å®šã—ã¦ãã ã•ã„。" + previewIsNotActual: "プレビュー画é¢ã§è¡¨ç¤ºå¯èƒ½ãªç¯„囲を超ãˆãŸãŸã‚ã€å®Ÿéš›ã«åŸ‹ã‚込んã éš›ã¨ã¯è¡¨ç¤ºãŒç•°ãªã‚Šã¾ã™ã€‚" + rounded: "角丸ã«ã™ã‚‹" + border: "å¤–æž ã«æž ç·šã‚’ã¤ã‘ã‚‹" + applyToPreview: "プレビューã«åæ˜ " + generateCode: "埋ã‚è¾¼ã¿ã‚³ãƒ¼ãƒ‰ã‚’作æˆ" + codeGenerated: "コードãŒç”Ÿæˆã•ã‚Œã¾ã—ãŸ" + codeGeneratedDescription: "生æˆã•ã‚ŒãŸã‚³ãƒ¼ãƒ‰ã‚’ウェブサイトã«è²¼ã‚Šä»˜ã‘ã¦ã”利用ãã ã•ã„。" diff --git a/package.json b/package.json index 310ea982147383d79a8931af9cab5b2c132ef94d..f6507acdb2403c5fe3e81a1dae38b7bda048555c 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ }, "packageManager": "pnpm@9.6.0", "workspaces": [ + "packages/frontend-shared", "packages/frontend", + "packages/frontend-embed", "packages/backend", "packages/sw", "packages/misskey-js", diff --git a/packages/backend/assets/embed.js b/packages/backend/assets/embed.js new file mode 100644 index 0000000000000000000000000000000000000000..24fccc1b6c1f4554e3c25e7327a7ee49e50d16db --- /dev/null +++ b/packages/backend/assets/embed.js @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: MIT + */ +//@ts-check +(() => { + /** @type {NodeListOf<HTMLIFrameElement>} */ + const els = document.querySelectorAll('iframe[data-misskey-embed-id]'); + + window.addEventListener('message', function (event) { + els.forEach((el) => { + if (event.source !== el.contentWindow) { + return; + } + + const id = el.dataset.misskeyEmbedId; + + if (event.data.type === 'misskey:embed:ready') { + el.contentWindow?.postMessage({ + type: 'misskey:embedParent:registerIframeId', + payload: { + iframeId: id, + } + }, '*'); + } + if (event.data.type === 'misskey:embed:changeHeight' && event.data.iframeId === id) { + el.style.height = event.data.payload.height + 'px'; + } + }); + }); +})(); diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index cff0194780cb50946ca004f81503efb6cd3ca98b..cbd6d1c086dcfe2e61d5ff9cb7279995d75f21df 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -160,8 +160,10 @@ export type Config = { authUrl: string; driveUrl: string; userAgent: string; - clientEntry: string; - clientManifestExists: boolean; + frontendEntry: string; + frontendManifestExists: boolean; + frontendEmbedEntry: string; + frontendEmbedManifestExists: boolean; mediaProxy: string; externalMediaProxyEnabled: boolean; videoThumbnailGenerator: string | null; @@ -196,10 +198,16 @@ const path = process.env.MISSKEY_CONFIG_YML export function loadConfig(): Config { const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/meta.json`, 'utf-8')); - const clientManifestExists = fs.existsSync(_dirname + '/../../../built/_vite_/manifest.json'); - const clientManifest = clientManifestExists ? - JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_vite_/manifest.json`, 'utf-8')) + + const frontendManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_vite_/manifest.json'); + const frontendEmbedManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_embed_vite_/manifest.json'); + const frontendManifest = frontendManifestExists ? + JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_vite_/manifest.json`, 'utf-8')) : { 'src/_boot_.ts': { file: 'src/_boot_.ts' } }; + const frontendEmbedManifest = frontendEmbedManifestExists ? + JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_embed_vite_/manifest.json`, 'utf-8')) + : { 'src/boot.ts': { file: 'src/boot.ts' } }; + const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source; const url = tryCreateUrl(config.url ?? process.env.MISSKEY_URL ?? ''); @@ -270,8 +278,10 @@ export function loadConfig(): Config { config.videoThumbnailGenerator.endsWith('/') ? config.videoThumbnailGenerator.substring(0, config.videoThumbnailGenerator.length - 1) : config.videoThumbnailGenerator : null, userAgent: `Misskey/${version} (${config.url})`, - clientEntry: clientManifest['src/_boot_.ts'], - clientManifestExists: clientManifestExists, + frontendEntry: frontendManifest['src/_boot_.ts'], + frontendManifestExists: frontendManifestExists, + frontendEmbedEntry: frontendEmbedManifest['src/boot.ts'], + frontendEmbedManifestExists: frontendEmbedManifestExists, perChannelMaxNoteCacheCount: config.perChannelMaxNoteCacheCount ?? 1000, perUserNotificationsMaxCount: config.perUserNotificationsMaxCount ?? 500, deactivateAntennaThreshold: config.deactivateAntennaThreshold ?? (1000 * 60 * 60 * 24 * 7), diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index f55790b6363be11bb65d0a09c57b3dc8759989ef..5e0ec390f2e838edfbc6680997583b3c830ecdd4 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -61,7 +61,8 @@ const staticAssets = `${_dirname}/../../../assets/`; const clientAssets = `${_dirname}/../../../../frontend/assets/`; const assets = `${_dirname}/../../../../../built/_frontend_dist_/`; const swAssets = `${_dirname}/../../../../../built/_sw_dist_/`; -const viteOut = `${_dirname}/../../../../../built/_vite_/`; +const frontendViteOut = `${_dirname}/../../../../../built/_frontend_vite_/`; +const frontendEmbedViteOut = `${_dirname}/../../../../../built/_frontend_embed_vite_/`; const tarball = `${_dirname}/../../../../../built/tarball/`; @Injectable() @@ -277,15 +278,22 @@ export class ClientServerService { }); //#region vite assets - if (this.config.clientManifestExists) { + if (this.config.frontendEmbedManifestExists) { fastify.register((fastify, options, done) => { fastify.register(fastifyStatic, { - root: viteOut, + root: frontendViteOut, prefix: '/vite/', maxAge: ms('30 days'), immutable: true, decorateReply: false, }); + fastify.register(fastifyStatic, { + root: frontendEmbedViteOut, + prefix: '/embed_vite/', + maxAge: ms('30 days'), + immutable: true, + decorateReply: false, + }); fastify.addHook('onRequest', handleRequestRedirectToOmitSearch); done(); }); @@ -296,6 +304,13 @@ export class ClientServerService { prefix: '/vite', rewritePrefix: '/vite', }); + + const embedPort = (process.env.EMBED_VITE_PORT ?? '5174'); + fastify.register(fastifyProxy, { + upstream: 'http://localhost:' + embedPort, + prefix: '/embed_vite', + rewritePrefix: '/embed_vite', + }); } //#endregion @@ -425,6 +440,13 @@ export class ClientServerService { // Manifest fastify.get('/manifest.json', async (request, reply) => await this.manifestHandler(reply)); + // Embed Javascript + fastify.get('/embed.js', async (request, reply) => { + return await reply.sendFile('/embed.js', staticAssets, { + maxAge: ms('1 day'), + }); + }); + fastify.get('/robots.txt', async (request, reply) => { return await reply.sendFile('/robots.txt', staticAssets); }); @@ -762,7 +784,7 @@ export class ClientServerService { }); //#endregion - //region noindex pages + //#region noindex pages // Tags fastify.get<{ Params: { clip: string; } }>('/tags/:tag', async (request, reply) => { return await renderBase(reply, { noindex: true }); @@ -772,7 +794,20 @@ export class ClientServerService { fastify.get<{ Params: { clip: string; } }>('/user-tags/:tag', async (request, reply) => { return await renderBase(reply, { noindex: true }); }); - //endregion + //#endregion + + //#region embed pages + fastify.get('/embed/*', async (request, reply) => { + const meta = await this.metaService.fetch(); + + reply.removeHeader('X-Frame-Options'); + + reply.header('Cache-Control', 'public, max-age=3600'); + return await reply.view('base-embed', { + title: meta.name ?? 'Misskey', + ...await this.generateCommonPugData(meta), + }); + }); fastify.get('/_info_card_', async (request, reply) => { const meta = await this.metaService.fetch(true); @@ -787,6 +822,7 @@ export class ClientServerService { originalNotesCount: await this.notesRepository.countBy({ userHost: IsNull() }), }); }); + //#endregion fastify.get('/bios', async (request, reply) => { return await reply.view('bios', { diff --git a/packages/backend/src/server/web/boot.embed.js b/packages/backend/src/server/web/boot.embed.js new file mode 100644 index 0000000000000000000000000000000000000000..48d1cd262bdce8cfebd665d5d0fc8e943eefd109 --- /dev/null +++ b/packages/backend/src/server/web/boot.embed.js @@ -0,0 +1,219 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +'use strict'; + +// ブãƒãƒƒã‚¯ã®ä¸ã«å…¥ã‚Œãªã„ã¨ã€å®šç¾©ã—ãŸå¤‰æ•°ãŒãƒ–ラウザã®ã‚°ãƒãƒ¼ãƒãƒ«ã‚¹ã‚³ãƒ¼ãƒ—ã«ç™»éŒ²ã•ã‚Œã¦ã—ã¾ã„邪é”ãªã®ã§ +(async () => { + window.onerror = (e) => { + console.error(e); + renderError('SOMETHING_HAPPENED'); + }; + window.onunhandledrejection = (e) => { + console.error(e); + renderError('SOMETHING_HAPPENED_IN_PROMISE'); + }; + + let forceError = localStorage.getItem('forceError'); + if (forceError != null) { + renderError('FORCED_ERROR', 'This error is forced by having forceError in local storage.'); + return; + } + + // パラメータã«å¿œã˜ã¦splashã®ã‚¹ã‚¿ã‚¤ãƒ«ã‚’変更 + const params = new URLSearchParams(location.search); + if (params.has('rounded') && params.get('rounded') === 'false') { + document.documentElement.classList.add('norounded'); + } + if (params.has('border') && params.get('border') === 'false') { + document.documentElement.classList.add('noborder'); + } + + //#region Detect language & fetch translations + if (!localStorage.hasOwnProperty('locale')) { + const supportedLangs = LANGS; + let lang = localStorage.getItem('lang'); + if (lang == null || !supportedLangs.includes(lang)) { + if (supportedLangs.includes(navigator.language)) { + lang = navigator.language; + } else { + lang = supportedLangs.find(x => x.split('-')[0] === navigator.language); + + // Fallback + if (lang == null) lang = 'en-US'; + } + } + + const metaRes = await window.fetch('/api/meta', { + method: 'POST', + body: JSON.stringify({}), + credentials: 'omit', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + }); + if (metaRes.status !== 200) { + renderError('META_FETCH'); + return; + } + const meta = await metaRes.json(); + const v = meta.version; + if (v == null) { + renderError('META_FETCH_V'); + return; + } + + // for https://github.com/misskey-dev/misskey/issues/10202 + if (lang == null || lang.toString == null || lang.toString() === 'null') { + console.error('invalid lang value detected!!!', typeof lang, lang); + lang = 'en-US'; + } + + const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`); + if (localRes.status === 200) { + localStorage.setItem('lang', lang); + localStorage.setItem('locale', await localRes.text()); + localStorage.setItem('localeVersion', v); + } else { + renderError('LOCALE_FETCH'); + return; + } + } + //#endregion + + //#region Script + async function importAppScript() { + await import(`/embed_vite/${CLIENT_ENTRY}`) + .catch(async e => { + console.error(e); + renderError('APP_IMPORT'); + }); + } + + // タイミングã«ã‚ˆã£ã¦ã¯ã€ã“ã®æ™‚点ã§DOMã®æ§‹ç¯‰ãŒæ¸ˆã‚“ã§ã„ã‚‹å ´åˆã¨ãã†ã§ãªã„å ´åˆã¨ãŒã‚ã‚‹ + if (document.readyState !== 'loading') { + importAppScript(); + } else { + window.addEventListener('DOMContentLoaded', () => { + importAppScript(); + }); + } + //#endregion + + async function addStyle(styleText) { + let css = document.createElement('style'); + css.appendChild(document.createTextNode(styleText)); + document.head.appendChild(css); + } + + async function renderError(code) { + // Cannot set property 'innerHTML' of null ã‚’å›žé¿ + if (document.readyState === 'loading') { + await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve)); + } + document.body.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /><path d="M12 9v4" /><path d="M12 16v.01" /></svg> + <div class="message">èªã¿è¾¼ã¿ã«å¤±æ•—ã—ã¾ã—ãŸ</div> + <div class="submessage">Failed to initialize Misskey</div> + <div class="submessage">Error Code: ${code}</div> + <button onclick="location.reload(!0)"> + <div>リãƒãƒ¼ãƒ‰</div> + <div><small>Reload</small></div> + </button>`; + addStyle(` + #misskey_app, + #splash { + display: none !important; + } + + html, + body { + margin: 0; + } + + body { + position: relative; + color: #dee7e4; + font-family: Hiragino Maru Gothic Pro, BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif; + line-height: 1.35; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + margin: 0; + padding: 24px; + box-sizing: border-box; + overflow: hidden; + + border-radius: var(--radius, 12px); + border: 1px solid rgba(231, 255, 251, 0.14); + } + + body::before { + content: ''; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: #192320; + border-radius: var(--radius, 12px); + z-index: -1; + } + + html.embed.norounded body, + html.embed.norounded body::before { + border-radius: 0; + } + + html.embed.noborder body { + border: none; + } + + .icon { + max-width: 60px; + width: 100%; + height: auto; + margin-bottom: 20px; + color: #dec340; + } + + .message { + text-align: center; + font-size: 20px; + font-weight: 700; + margin-bottom: 20px; + } + + .submessage { + text-align: center; + font-size: 90%; + margin-bottom: 7.5px; + } + + .submessage:last-of-type { + margin-bottom: 20px; + } + + button { + padding: 7px 14px; + min-width: 100px; + font-weight: 700; + font-family: Hiragino Maru Gothic Pro, BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif; + line-height: 1.35; + border-radius: 99rem; + background-color: #b4e900; + color: #192320; + border: none; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + } + + button:hover { + background-color: #c6ff03; + }`); + } +})(); diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js index 5283596316ea72af01d911fc8ea6e2cfd664f802..7c6a5334291da2cfca3d5602d104b64a4db509ef 100644 --- a/packages/backend/src/server/web/boot.js +++ b/packages/backend/src/server/web/boot.js @@ -3,17 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -/** - * BOOT LOADER - * サーãƒãƒ¼ã‹ã‚‰ãƒ¬ã‚¹ãƒãƒ³ã‚¹ã•ã‚Œã‚‹HTMLã«åŸ‹ã‚è¾¼ã¾ã‚Œã‚‹ã‚¹ã‚¯ãƒªãƒ—トã§ã€ä»¥ä¸‹ã®å½¹å‰²ã‚’æŒã¡ã¾ã™ã€‚ - * - 翻訳ファイルをフェッãƒã™ã‚‹ã€‚ - * - ãƒãƒ¼ã‚¸ãƒ§ãƒ³ã«åŸºã¥ã„ã¦é©åˆ‡ãªãƒ¡ã‚¤ãƒ³ã‚¹ã‚¯ãƒªãƒ—トをèªã¿è¾¼ã‚€ã€‚ - * - ã‚ャッシュã•ã‚ŒãŸã‚³ãƒ³ãƒ‘イル済ã¿ãƒ†ãƒ¼ãƒžã‚’é©ç”¨ã™ã‚‹ã€‚ - * - クライアントã®è¨å®šå€¤ã«åŸºã¥ã„ã¦å¯¾å¿œã™ã‚‹HTMLクラスç‰ã‚’è¨å®šã™ã‚‹ã€‚ - * テーマをã“ã®æ®µéšŽã§è¨å®šã™ã‚‹ã®ã¯ã€ãƒ¡ã‚¤ãƒ³ã‚¹ã‚¯ãƒªãƒ—トãŒèªã¿è¾¼ã¾ã‚Œã‚‹é–“もテーマをé©ç”¨ã—ãŸã„ãŸã‚ã§ã™ã€‚ - * 注: webpackã¯ä»‹ã•ãªã„ãŸã‚ã€ã“ã®ãƒ•ã‚¡ã‚¤ãƒ«ã§ã¯requireã‚„importã¯ä½¿ãˆã¾ã›ã‚“。 - */ - 'use strict'; // ブãƒãƒƒã‚¯ã®ä¸ã«å…¥ã‚Œãªã„ã¨ã€å®šç¾©ã—ãŸå¤‰æ•°ãŒãƒ–ラウザã®ã‚°ãƒãƒ¼ãƒãƒ«ã‚¹ã‚³ãƒ¼ãƒ—ã«ç™»éŒ²ã•ã‚Œã¦ã—ã¾ã„邪é”ãªã®ã§ diff --git a/packages/backend/src/server/web/style.css b/packages/backend/src/server/web/style.css index e4723c24fd2dd751b4b13351fc7fbd5c1bb8860a..dbcc8f537cdd66b3b01605f3469a69a047ba0ffd 100644 --- a/packages/backend/src/server/web/style.css +++ b/packages/backend/src/server/web/style.css @@ -47,6 +47,7 @@ html { transform: translateY(70px); color: var(--accent); } + #splashSpinner > .spinner { position: absolute; top: 0; diff --git a/packages/backend/src/server/web/style.embed.css b/packages/backend/src/server/web/style.embed.css new file mode 100644 index 0000000000000000000000000000000000000000..a7b110d80a1098e0b2db61174bccd85fe4e1f6a6 --- /dev/null +++ b/packages/backend/src/server/web/style.embed.css @@ -0,0 +1,99 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +html { + background-color: var(--bg); + color: var(--fg); +} + +html.embed { + box-sizing: border-box; + background-color: transparent; + color-scheme: light dark; + max-width: 500px; +} + +#splash { + position: fixed; + z-index: 10000; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + cursor: wait; + background-color: var(--bg); + opacity: 1; + transition: opacity 0.5s ease; +} + +html.embed #splash { + box-sizing: border-box; + min-height: 300px; + border-radius: var(--radius, 12px); + border: 1px solid var(--divider, #e8e8e8); +} + +html.embed.norounded #splash { + border-radius: 0; +} + +html.embed.noborder #splash { + border: none; +} + +#splashIcon { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + margin: auto; + width: 64px; + height: 64px; + pointer-events: none; +} + +#splashSpinner { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + margin: auto; + display: inline-block; + width: 28px; + height: 28px; + transform: translateY(70px); + color: var(--accent); +} + +#splashSpinner > .spinner { + position: absolute; + top: 0; + left: 0; + width: 28px; + height: 28px; + fill-rule: evenodd; + clip-rule: evenodd; + stroke-linecap: round; + stroke-linejoin: round; + stroke-miterlimit: 1.5; +} +#splashSpinner > .spinner.bg { + opacity: 0.275; +} +#splashSpinner > .spinner.fg { + animation: splashSpinner 0.5s linear infinite; +} + +@keyframes splashSpinner { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/packages/backend/src/server/web/views/base-embed.pug b/packages/backend/src/server/web/views/base-embed.pug new file mode 100644 index 0000000000000000000000000000000000000000..d773f2676a59db34a5010a64845a5e35e52e7591 --- /dev/null +++ b/packages/backend/src/server/web/views/base-embed.pug @@ -0,0 +1,67 @@ +block vars + +block loadClientEntry + - const entry = config.frontendEmbedEntry; + +doctype html + +html(class='embed') + + head + meta(charset='utf-8') + meta(name='application-name' content='Misskey') + meta(name='referrer' content='origin') + meta(name='theme-color' content= themeColor || '#86b300') + meta(name='theme-color-orig' content= themeColor || '#86b300') + meta(name='viewport' content='width=device-width, initial-scale=1') + meta(name='format-detection' content='telephone=no,date=no,address=no,email=no,url=no') + link(rel='icon' href= icon || '/favicon.ico') + link(rel='apple-touch-icon' href= appleTouchIcon || '/apple-touch-icon.png') + link(rel='modulepreload' href=`/embed_vite/${entry.file}`) + + if !config.frontendEmbedManifestExists + script(type="module" src="/embed_vite/@vite/client") + + if Array.isArray(entry.css) + each href in entry.css + link(rel='stylesheet' href=`/embed_vite/${href}`) + + title + block title + = title || 'Misskey' + + block meta + meta(name='robots' content='noindex') + + style + include ../style.embed.css + + script. + var VERSION = "#{version}"; + var CLIENT_ENTRY = "#{entry.file}"; + + script(type='application/json' id='misskey_meta' data-generated-at=now) + != metaJson + + script + include ../boot.embed.js + + body + noscript: p + | JavaScriptを有効ã«ã—ã¦ãã ã•ã„ + br + | Please turn on your JavaScript + div#splash + img#splashIcon(src= icon || '/static-assets/splash.png') + div#splashSpinner + <svg class="spinner bg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg"> + <g transform="matrix(1,0,0,1,12,12)"> + <circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:24px;"/> + </g> + </svg> + <svg class="spinner fg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg"> + <g transform="matrix(1,0,0,1,12,12)"> + <path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:24px;"/> + </g> + </svg> + block content diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug index da6d1eafd3c0395644c45350f95f938e1caaabe0..88714b255641c7544c5f4f84ac8482fa9b76918f 100644 --- a/packages/backend/src/server/web/views/base.pug +++ b/packages/backend/src/server/web/views/base.pug @@ -1,7 +1,7 @@ block vars block loadClientEntry - - const clientEntry = config.clientEntry; + - const entry = config.frontendEntry; doctype html @@ -36,13 +36,13 @@ html link(rel='prefetch' href=serverErrorImageUrl) link(rel='prefetch' href=infoImageUrl) link(rel='prefetch' href=notFoundImageUrl) - link(rel='modulepreload' href=`/vite/${clientEntry.file}`) + link(rel='modulepreload' href=`/vite/${entry.file}`) - if !config.clientManifestExists + if !config.frontendManifestExists script(type="module" src="/vite/@vite/client") - if Array.isArray(clientEntry.css) - each href in clientEntry.css + if Array.isArray(entry.css) + each href in entry.css link(rel='stylesheet' href=`/vite/${href}`) title @@ -68,7 +68,7 @@ html script. var VERSION = "#{version}"; - var CLIENT_ENTRY = "#{clientEntry.file}"; + var CLIENT_ENTRY = "#{entry.file}"; script(type='application/json' id='misskey_meta' data-generated-at=now) != metaJson diff --git a/packages/frontend-embed/.gitignore b/packages/frontend-embed/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..1aa0ac14e8288adbba89868e9b9b1f231342679e --- /dev/null +++ b/packages/frontend-embed/.gitignore @@ -0,0 +1 @@ +/storybook-static diff --git a/packages/frontend-embed/@types/global.d.ts b/packages/frontend-embed/@types/global.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..1025d1bedbb8a6f69fef263191c43bbd677548be --- /dev/null +++ b/packages/frontend-embed/@types/global.d.ts @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +type FIXME = any; + +declare const _LANGS_: string[][]; +declare const _VERSION_: string; +declare const _ENV_: string; +declare const _DEV_: boolean; +declare const _PERF_PREFIX_: string; +declare const _DATA_TRANSFER_DRIVE_FILE_: string; +declare const _DATA_TRANSFER_DRIVE_FOLDER_: string; +declare const _DATA_TRANSFER_DECK_COLUMN_: string; + +// for dev-mode +declare const _LANGS_FULL_: string[][]; + +// TagCanvas +interface Window { + TagCanvas: any; +} diff --git a/packages/frontend-embed/@types/theme.d.ts b/packages/frontend-embed/@types/theme.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..6ac1037493e672d5f38e593fcfb5e51b0cd6ef69 --- /dev/null +++ b/packages/frontend-embed/@types/theme.d.ts @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +declare module '@@/themes/*.json5' { + import { Theme } from '@/theme.js'; + + const theme: Theme; + + export default theme; +} diff --git a/packages/frontend-embed/assets/dummy.png b/packages/frontend-embed/assets/dummy.png new file mode 100644 index 0000000000000000000000000000000000000000..39332b0c1beeda1edb90d78d25c16e7372aff030 Binary files /dev/null and b/packages/frontend-embed/assets/dummy.png differ diff --git a/packages/frontend-embed/eslint.config.js b/packages/frontend-embed/eslint.config.js new file mode 100644 index 0000000000000000000000000000000000000000..dd8f03dac5102ecb648e0beb6516a40c5421a621 --- /dev/null +++ b/packages/frontend-embed/eslint.config.js @@ -0,0 +1,95 @@ +import globals from 'globals'; +import tsParser from '@typescript-eslint/parser'; +import parser from 'vue-eslint-parser'; +import pluginVue from 'eslint-plugin-vue'; +import pluginMisskey from '@misskey-dev/eslint-plugin'; +import sharedConfig from '../shared/eslint.config.js'; + +export default [ + ...sharedConfig, + { + files: ['src/**/*.vue'], + ...pluginMisskey.configs.typescript, + }, + ...pluginVue.configs['flat/recommended'], + { + files: ['src/**/*.{ts,vue}'], + languageOptions: { + globals: { + ...Object.fromEntries(Object.entries(globals.node).map(([key]) => [key, 'off'])), + ...globals.browser, + + // Node.js + module: false, + require: false, + __dirname: false, + + // Misskey + _DEV_: false, + _LANGS_: false, + _VERSION_: false, + _ENV_: false, + _PERF_PREFIX_: false, + _DATA_TRANSFER_DRIVE_FILE_: false, + _DATA_TRANSFER_DRIVE_FOLDER_: false, + _DATA_TRANSFER_DECK_COLUMN_: false, + }, + parser, + parserOptions: { + extraFileExtensions: ['.vue'], + parser: tsParser, + project: ['./tsconfig.json'], + sourceType: 'module', + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + '@typescript-eslint/no-empty-interface': ['error', { + allowSingleExtends: true, + }], + // window ã®ç¦æ¢ç†ç”±: ã‚°ãƒãƒ¼ãƒãƒ«ã‚¹ã‚³ãƒ¼ãƒ—ã¨è¡çªã—ã€äºˆæœŸã›ã¬çµæžœã‚’æ‹›ããŸã‚ + // e ã®ç¦æ¢ç†ç”±: error ã‚„ event ãªã©ã€è¤‡æ•°ã®ã‚ーワードã®é æ–‡å—ã§ã‚り分ã‹ã‚Šã«ãã„ãŸã‚ + 'id-denylist': ['error', 'window', 'e'], + 'no-shadow': ['warn'], + 'vue/attributes-order': ['error', { + alphabetical: false, + }], + 'vue/no-use-v-if-with-v-for': ['error', { + allowUsingIterationVar: false, + }], + 'vue/no-ref-as-operand': 'error', + 'vue/no-multi-spaces': ['error', { + ignoreProperties: false, + }], + 'vue/no-v-html': 'warn', + 'vue/order-in-components': 'error', + 'vue/html-indent': ['warn', 'tab', { + attribute: 1, + baseIndent: 0, + closeBracket: 0, + alignAttributesVertically: true, + ignores: [], + }], + 'vue/html-closing-bracket-spacing': ['warn', { + startTag: 'never', + endTag: 'never', + selfClosingTag: 'never', + }], + 'vue/multi-word-component-names': 'warn', + 'vue/require-v-for-key': 'warn', + 'vue/no-unused-components': 'warn', + 'vue/no-unused-vars': 'warn', + 'vue/no-dupe-keys': 'warn', + 'vue/valid-v-for': 'warn', + 'vue/return-in-computed-property': 'warn', + 'vue/no-setup-props-reactivity-loss': 'warn', + 'vue/max-attributes-per-line': 'off', + 'vue/html-self-closing': 'off', + 'vue/singleline-html-element-content-newline': 'off', + 'vue/v-on-event-hyphenation': ['error', 'never', { + autofix: true, + }], + 'vue/attribute-hyphenation': ['error', 'never'], + }, + }, +]; diff --git a/packages/frontend-embed/package.json b/packages/frontend-embed/package.json new file mode 100644 index 0000000000000000000000000000000000000000..a65d6ab657b85d40edf518c41d92f9274f0c7195 --- /dev/null +++ b/packages/frontend-embed/package.json @@ -0,0 +1,85 @@ +{ + "name": "frontend-embed", + "private": true, + "type": "module", + "scripts": { + "watch": "vite", + "dev": "vite --config vite.config.local-dev.ts --debug hmr", + "build": "vite build", + "typecheck": "vue-tsc --noEmit", + "eslint": "eslint --quiet \"src/**/*.{ts,vue}\"", + "lint": "pnpm typecheck && pnpm eslint" + }, + "dependencies": { + "@discordapp/twemoji": "15.0.3", + "@github/webauthn-json": "2.1.1", + "@rollup/plugin-json": "6.1.0", + "@rollup/plugin-replace": "5.0.7", + "@rollup/pluginutils": "5.1.0", + "@tabler/icons-webfont": "3.3.0", + "@twemoji/parser": "15.1.1", + "@vitejs/plugin-vue": "5.1.0", + "@vue/compiler-sfc": "3.4.37", + "astring": "1.8.6", + "buraha": "0.0.1", + "compare-versions": "6.1.1", + "date-fns": "2.30.0", + "escape-regexp": "0.0.1", + "estree-walker": "3.0.3", + "eventemitter3": "5.0.1", + "idb-keyval": "6.2.1", + "is-file-animated": "1.0.2", + "mfm-js": "0.24.0", + "misskey-js": "workspace:*", + "frontend-shared": "workspace:*", + "punycode": "2.3.1", + "rollup": "4.19.1", + "sanitize-html": "2.13.0", + "sass": "1.77.8", + "shiki": "1.12.0", + "strict-event-emitter-types": "2.0.0", + "throttle-debounce": "5.0.2", + "tinycolor2": "1.6.0", + "tsc-alias": "1.8.10", + "tsconfig-paths": "4.2.0", + "typescript": "5.5.4", + "uuid": "10.0.0", + "json5": "2.2.3", + "vite": "5.3.5", + "vue": "3.4.37" + }, + "devDependencies": { + "@misskey-dev/summaly": "5.1.0", + "@testing-library/vue": "8.1.0", + "@types/escape-regexp": "0.0.3", + "@types/estree": "1.0.5", + "@types/micromatch": "4.0.9", + "@types/node": "20.14.12", + "@types/punycode": "2.1.4", + "@types/sanitize-html": "2.11.0", + "@types/throttle-debounce": "5.0.2", + "@types/tinycolor2": "1.4.6", + "@types/uuid": "10.0.0", + "@types/ws": "8.5.11", + "@typescript-eslint/eslint-plugin": "7.17.0", + "@typescript-eslint/parser": "7.17.0", + "@vitest/coverage-v8": "1.6.0", + "@vue/runtime-core": "3.4.37", + "acorn": "8.12.1", + "cross-env": "7.0.3", + "eslint-plugin-import": "2.29.1", + "eslint-plugin-vue": "9.27.0", + "fast-glob": "3.3.2", + "happy-dom": "10.0.3", + "intersection-observer": "0.12.2", + "micromatch": "4.0.7", + "msw": "2.3.4", + "nodemon": "3.1.4", + "prettier": "3.3.3", + "start-server-and-test": "2.0.4", + "vite-plugin-turbosnap": "1.0.3", + "vue-component-type-helpers": "2.0.29", + "vue-eslint-parser": "9.4.3", + "vue-tsc": "2.0.29" + } +} diff --git a/packages/frontend-embed/src/boot.ts b/packages/frontend-embed/src/boot.ts new file mode 100644 index 0000000000000000000000000000000000000000..4676baa9056e8aea525ddcd426255c0801557b31 --- /dev/null +++ b/packages/frontend-embed/src/boot.ts @@ -0,0 +1,114 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// https://vitejs.dev/config/build-options.html#build-modulepreload +import 'vite/modulepreload-polyfill'; + +import '@tabler/icons-webfont/dist/tabler-icons.scss'; + +import '@/style.scss'; +import { createApp, defineAsyncComponent } from 'vue'; +import lightTheme from '@@/themes/l-light.json5'; +import darkTheme from '@@/themes/d-dark.json5'; +import { MediaProxy } from '@@/js/media-proxy.js'; +import { applyTheme } from './theme.js'; +import { fetchCustomEmojis } from './custom-emojis.js'; +import { DI } from './di.js'; +import { serverMetadata } from './server-metadata.js'; +import { url } from './config.js'; +import { parseEmbedParams } from '@@/js/embed-page.js'; +import { postMessageToParentWindow, setIframeId } from '@/post-message.js'; + +console.info('Misskey Embed'); + +const params = new URLSearchParams(location.search); +const embedParams = parseEmbedParams(params); + +console.info(embedParams); + +if (embedParams.colorMode === 'dark') { + applyTheme(darkTheme); +} else if (embedParams.colorMode === 'light') { + applyTheme(lightTheme); +} else { + if (window.matchMedia('(prefers-color-scheme: dark)').matches) { + applyTheme(darkTheme); + } else { + applyTheme(lightTheme); + } + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (mql) => { + if (mql.matches) { + applyTheme(darkTheme); + } else { + applyTheme(lightTheme); + } + }); +} + +// サイズã®åˆ¶é™ +document.documentElement.style.maxWidth = '500px'; + +// iframeIdã®è¨å®š +function setIframeIdHandler(event: MessageEvent) { + if (event.data?.type === 'misskey:embedParent:registerIframeId' && event.data.payload?.iframeId != null) { + setIframeId(event.data.payload.iframeId); + window.removeEventListener('message', setIframeIdHandler); + } +} + +window.addEventListener('message', setIframeIdHandler); + +try { + await fetchCustomEmojis(); +} catch (err) { /* empty */ } + +const app = createApp( + defineAsyncComponent(() => import('@/ui.vue')), +); + +app.provide(DI.mediaProxy, new MediaProxy(serverMetadata, url)); + +app.provide(DI.embedParams, embedParams); + +// https://github.com/misskey-dev/misskey/pull/8575#issuecomment-1114239210 +// ãªãœã‹2回実行ã•ã‚Œã‚‹ã“ã¨ãŒã‚ã‚‹ãŸã‚ã€mountã™ã‚‹divã‚’1ã¤ã«åˆ¶é™ã™ã‚‹ +const rootEl = ((): HTMLElement => { + const MISSKEY_MOUNT_DIV_ID = 'misskey_app'; + + const currentRoot = document.getElementById(MISSKEY_MOUNT_DIV_ID); + + if (currentRoot) { + console.warn('multiple import detected'); + return currentRoot; + } + + const root = document.createElement('div'); + root.id = MISSKEY_MOUNT_DIV_ID; + document.body.appendChild(root); + return root; +})(); + +postMessageToParentWindow('misskey:embed:ready'); + +app.mount(rootEl); + +// boot.jsã®ã‚„ã¤ã‚’解除 +window.onerror = null; +window.onunhandledrejection = null; + +removeSplash(); + +function removeSplash() { + const splash = document.getElementById('splash'); + if (splash) { + splash.style.opacity = '0'; + splash.style.pointerEvents = 'none'; + + // transitionendイベントãŒç™ºç«ã—ãªã„å ´åˆãŒã‚ã‚‹ãŸã‚ + window.setTimeout(() => { + splash.remove(); + }, 1000); + } +} diff --git a/packages/frontend-embed/src/components/EmA.vue b/packages/frontend-embed/src/components/EmA.vue new file mode 100644 index 0000000000000000000000000000000000000000..1c236b9a3592e32e189366dec590aa72ab9ecb4f --- /dev/null +++ b/packages/frontend-embed/src/components/EmA.vue @@ -0,0 +1,21 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<a :href="to" target="_blank" rel="noopener"> + <slot></slot> +</a> +</template> + +<script lang="ts" setup> +import { } from 'vue'; + +const props = withDefaults(defineProps<{ + to: string; + activeClass?: null | string; +}>(), { + activeClass: null, +}); +</script> diff --git a/packages/frontend-embed/src/components/EmAcct.vue b/packages/frontend-embed/src/components/EmAcct.vue new file mode 100644 index 0000000000000000000000000000000000000000..07315e6a8b7cbb6576b4dcd084e6e89bdeeffe92 --- /dev/null +++ b/packages/frontend-embed/src/components/EmAcct.vue @@ -0,0 +1,24 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<span> + <span>@{{ user.username }}</span> + <span v-if="user.host || detail" style="opacity: 0.5;">@{{ user.host || host }}</span> +</span> +</template> + +<script lang="ts" setup> +import * as Misskey from 'misskey-js'; +import { toUnicode } from 'punycode/'; +import { host as hostRaw } from '@/config.js'; + +defineProps<{ + user: Misskey.entities.UserLite; + detail?: boolean; +}>(); + +const host = toUnicode(hostRaw); +</script> diff --git a/packages/frontend-embed/src/components/EmAvatar.vue b/packages/frontend-embed/src/components/EmAvatar.vue new file mode 100644 index 0000000000000000000000000000000000000000..58c35c8ef0cd96b18fe3ac53aef8defecd46dad3 --- /dev/null +++ b/packages/frontend-embed/src/components/EmAvatar.vue @@ -0,0 +1,250 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<component :is="link ? EmA : 'span'" v-bind="bound" class="_noSelect" :class="[$style.root, { [$style.cat]: user.isCat }]"> + <EmImgWithBlurhash :class="$style.inner" :src="url" :hash="user.avatarBlurhash" :cover="true" :onlyAvgColor="true"/> + <div v-if="user.isCat" :class="[$style.ears]"> + <div :class="$style.earLeft"> + <div v-if="false" :class="$style.layer"> + <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/> + <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/> + <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/> + </div> + </div> + <div :class="$style.earRight"> + <div v-if="false" :class="$style.layer"> + <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/> + <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/> + <div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/> + </div> + </div> + </div> + <img + v-for="decoration in user.avatarDecorations" + :class="[$style.decoration]" + :src="getDecorationUrl(decoration)" + :style="{ + rotate: getDecorationAngle(decoration), + scale: getDecorationScale(decoration), + translate: getDecorationOffset(decoration), + }" + alt="" + > +</component> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import * as Misskey from 'misskey-js'; +import EmImgWithBlurhash from './EmImgWithBlurhash.vue'; +import EmA from './EmA.vue'; +import { userPage } from '@/utils.js'; + +const props = withDefaults(defineProps<{ + user: Misskey.entities.User; + link?: boolean; + preview?: boolean; + indicator?: boolean; +}>(), { + link: false, + preview: false, + indicator: false, +}); + +const emit = defineEmits<{ + (ev: 'click', v: MouseEvent): void; +}>(); + +const bound = computed(() => props.link + ? { to: userPage(props.user) } + : {}); + +const url = computed(() => { + if (props.user.avatarUrl == null) return null; + return props.user.avatarUrl; +}); + +function getDecorationUrl(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) { + return decoration.url; +} + +function getDecorationAngle(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) { + const angle = decoration.angle ?? 0; + return angle === 0 ? undefined : `${angle * 360}deg`; +} + +function getDecorationScale(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) { + const scaleX = decoration.flipH ? -1 : 1; + return scaleX === 1 ? undefined : `${scaleX} 1`; +} + +function getDecorationOffset(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) { + const offsetX = decoration.offsetX ?? 0; + const offsetY = decoration.offsetY ?? 0; + return offsetX === 0 && offsetY === 0 ? undefined : `${offsetX * 100}% ${offsetY * 100}%`; +} +</script> + +<style lang="scss" module> +.root { + position: relative; + display: inline-block; + vertical-align: bottom; + flex-shrink: 0; + border-radius: 100%; + line-height: 16px; +} + +.inner { + position: absolute; + bottom: 0; + left: 0; + right: 0; + top: 0; + border-radius: 100%; + z-index: 1; + overflow: clip; + object-fit: cover; + width: 100%; + height: 100%; +} + +.indicator { + position: absolute; + z-index: 2; + bottom: 0; + left: 0; + width: 20%; + height: 20%; +} + +.cat { + > .ears { + contain: strict; + position: absolute; + top: -50%; + left: -50%; + width: 100%; + height: 100%; + padding: 50%; + pointer-events: none; + + > .earLeft, + > .earRight { + contain: strict; + display: inline-block; + height: 50%; + width: 50%; + background: currentColor; + + &::after { + contain: strict; + content: ''; + display: block; + width: 60%; + height: 60%; + margin: 20%; + background: #df548f; + } + + > .layer { + contain: strict; + position: absolute; + top: 0; + width: 280%; + height: 280%; + + > .plot { + contain: strict; + position: absolute; + width: 100%; + height: 100%; + clip-path: path('M0 0H1V1H0z'); + transform: scale(32767); + transform-origin: 0 0; + opacity: 0.5; + + &:first-child { + opacity: 1; + } + + &:last-child { + opacity: calc(1 / 3); + } + } + } + } + + > .earLeft { + transform: rotate(37.5deg) skew(30deg); + + &, &::after { + border-radius: 25% 75% 75%; + } + + > .layer { + left: 0; + transform: + skew(-30deg) + rotate(-37.5deg) + translate(-2.82842712475%, /* -2 * sqrt(2) */ + -38.5857864376%); /* 40 - 2 * sqrt(2) */ + + > .plot { + background-position: 20% 10%; /* ~= 37.5deg */ + + &:first-child { + background-position-x: 21%; + } + + &:last-child { + background-position-y: 11%; + } + } + } + } + + > .earRight { + transform: rotate(-37.5deg) skew(-30deg); + + &, &::after { + border-radius: 75% 25% 75% 75%; + } + + > .layer { + right: 0; + transform: + skew(30deg) + rotate(37.5deg) + translate(2.82842712475%, /* 2 * sqrt(2) */ + -38.5857864376%); /* 40 - 2 * sqrt(2) */ + + > .plot { + position: absolute; + background-position: 80% 10%; /* ~= 37.5deg */ + + &:first-child { + background-position-x: 79%; + } + + &:last-child { + background-position-y: 11%; + } + } + } + } + } +} + +.decoration { + position: absolute; + z-index: 1; + top: -50%; + left: -50%; + width: 200%; + pointer-events: none; +} +</style> diff --git a/packages/frontend-embed/src/components/EmCustomEmoji.vue b/packages/frontend-embed/src/components/EmCustomEmoji.vue new file mode 100644 index 0000000000000000000000000000000000000000..e4149cf363f33082203633eafb9d143b40603980 --- /dev/null +++ b/packages/frontend-embed/src/components/EmCustomEmoji.vue @@ -0,0 +1,101 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<img + v-if="errored && fallbackToImage" + :class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]" + src="/client-assets/dummy.png" + :title="alt" +/> +<span v-else-if="errored">:{{ customEmojiName }}:</span> +<img + v-else + :class="[$style.root, { [$style.normal]: normal, [$style.noStyle]: noStyle }]" + :src="url" + :alt="alt" + :title="alt" + decoding="async" + @error="errored = true" + @load="errored = false" +/> +</template> + +<script lang="ts" setup> +import { computed, inject, ref } from 'vue'; +import { customEmojisMap } from '@/custom-emojis.js'; + +import { DI } from '@/di.js'; + +const mediaProxy = inject(DI.mediaProxy)!; + +const props = defineProps<{ + name: string; + normal?: boolean; + noStyle?: boolean; + host?: string | null; + url?: string; + useOriginalSize?: boolean; + menu?: boolean; + menuReaction?: boolean; + fallbackToImage?: boolean; +}>(); + +const customEmojiName = computed(() => (props.name[0] === ':' ? props.name.substring(1, props.name.length - 1) : props.name).replace('@.', '')); +const isLocal = computed(() => !props.host && (customEmojiName.value.endsWith('@.') || !customEmojiName.value.includes('@'))); + +const rawUrl = computed(() => { + if (props.url) { + return props.url; + } + if (isLocal.value) { + return customEmojisMap.get(customEmojiName.value)?.url ?? null; + } + return props.host ? `/emoji/${customEmojiName.value}@${props.host}.webp` : `/emoji/${customEmojiName.value}.webp`; +}); + +const url = computed(() => { + if (rawUrl.value == null) return undefined; + + const proxied = + (rawUrl.value.startsWith('/emoji/') || (props.useOriginalSize && isLocal.value)) + ? rawUrl.value + : mediaProxy.getProxiedImageUrl( + rawUrl.value, + props.useOriginalSize ? undefined : 'emoji', + false, + true, + ); + return proxied; +}); + +const alt = computed(() => `:${customEmojiName.value}:`); +const errored = ref(url.value == null); +</script> + +<style lang="scss" module> +.root { + height: 2em; + vertical-align: middle; + transition: transform 0.2s ease; + + &:hover { + transform: scale(1.2); + } +} + +.normal { + height: 1.25em; + vertical-align: -0.25em; + + &:hover { + transform: none; + } +} + +.noStyle { + height: auto !important; +} +</style> diff --git a/packages/frontend-embed/src/components/EmEmoji.vue b/packages/frontend-embed/src/components/EmEmoji.vue new file mode 100644 index 0000000000000000000000000000000000000000..224979707b971c0a5e84c87a7619944946f9c358 --- /dev/null +++ b/packages/frontend-embed/src/components/EmEmoji.vue @@ -0,0 +1,26 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<img :class="$style.root" :src="url" :alt="props.emoji" decoding="async"/> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import { char2twemojiFilePath } from '@@/js/emoji-base.js'; + +const props = defineProps<{ + emoji: string; +}>(); + +const url = computed(() => char2twemojiFilePath(props.emoji)); +</script> + +<style lang="scss" module> +.root { + height: 1.25em; + vertical-align: -0.25em; +} +</style> diff --git a/packages/frontend-embed/src/components/EmError.vue b/packages/frontend-embed/src/components/EmError.vue new file mode 100644 index 0000000000000000000000000000000000000000..d376b29a7f85cc3926cc02db18ee603cde187181 --- /dev/null +++ b/packages/frontend-embed/src/components/EmError.vue @@ -0,0 +1,43 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.root"> + <p :class="$style.text"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.somethingHappened }}</p> + <button class="_buttonGray _buttonRounded" :class="$style.button" @click="() => emit('retry')">{{ i18n.ts.retry }}</button> +</div> +</template> + +<script lang="ts" setup> +import { i18n } from '@/i18n.js'; + +const emit = defineEmits<{ + (ev: 'retry'): void; +}>(); +</script> + +<style lang="scss" module> +.root { + padding: 32px; + text-align: center; + align-items: center; +} + +.text { + margin: 0 0 8px 0; +} + +.button { + margin: 0 auto; +} + +.img { + vertical-align: bottom; + width: 128px; + height: 128px; + margin-bottom: 16px; + border-radius: 16px; +} +</style> diff --git a/packages/frontend-embed/src/components/EmImgWithBlurhash.vue b/packages/frontend-embed/src/components/EmImgWithBlurhash.vue new file mode 100644 index 0000000000000000000000000000000000000000..d19cd08d0a0791280e787132520f9389a14f792e --- /dev/null +++ b/packages/frontend-embed/src/components/EmImgWithBlurhash.vue @@ -0,0 +1,240 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div ref="root" :class="['chromatic-ignore', $style.root, { [$style.cover]: cover }]" :title="title ?? ''"> + <canvas v-show="hide" key="canvas" ref="canvas" :class="$style.canvas" :width="canvasWidth" :height="canvasHeight" :title="title ?? undefined" tabindex="-1"/> + <img v-show="!hide" key="img" ref="img" :height="imgHeight ?? undefined" :width="imgWidth ?? undefined" :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined" loading="eager" decoding="async" tabindex="-1"/> +</div> +</template> + +<script lang="ts"> +import DrawBlurhash from '@/workers/draw-blurhash?worker'; +import TestWebGL2 from '@/workers/test-webgl2?worker'; +import { WorkerMultiDispatch } from '@/to-be-shared/worker-multi-dispatch.js'; +import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js'; + +const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => { + // テスト環境㧠Web Worker インスタンスã¯ä½œæˆã§ããªã„ + if (import.meta.env.MODE === 'test') { + const canvas = document.createElement('canvas'); + canvas.width = 64; + canvas.height = 64; + resolve(canvas); + return; + } + const testWorker = new TestWebGL2(); + testWorker.addEventListener('message', event => { + if (event.data.result) { + const workers = new WorkerMultiDispatch( + () => new DrawBlurhash(), + Math.min(navigator.hardwareConcurrency - 1, 4), + ); + resolve(workers); + if (_DEV_) console.log('WebGL2 in worker is supported!'); + } else { + const canvas = document.createElement('canvas'); + canvas.width = 64; + canvas.height = 64; + resolve(canvas); + if (_DEV_) console.log('WebGL2 in worker is not supported...'); + } + testWorker.terminate(); + }); +}); +</script> + +<script lang="ts" setup> +import { computed, nextTick, onMounted, onUnmounted, shallowRef, watch, ref } from 'vue'; +import { v4 as uuid } from 'uuid'; +import { render } from 'buraha'; + +const props = withDefaults(defineProps<{ + src?: string | null; + hash?: string | null; + alt?: string | null; + title?: string | null; + height?: number; + width?: number; + cover?: boolean; + forceBlurhash?: boolean; + onlyAvgColor?: boolean; // 軽é‡åŒ–ã®ãŸã‚ã«Blurhashを使ã‚ãšã«å¹³å‡è‰²ã ã‘ã‚’æç”» +}>(), { + src: null, + alt: '', + title: null, + height: 64, + width: 64, + cover: true, + forceBlurhash: false, + onlyAvgColor: false, +}); + +const viewId = uuid(); +const canvas = shallowRef<HTMLCanvasElement>(); +const root = shallowRef<HTMLDivElement>(); +const img = shallowRef<HTMLImageElement>(); +const loaded = ref(false); +const canvasWidth = ref(64); +const canvasHeight = ref(64); +const imgWidth = ref(props.width); +const imgHeight = ref(props.height); +const bitmapTmp = ref<CanvasImageSource | undefined>(); +const hide = computed(() => !loaded.value || props.forceBlurhash); + +function waitForDecode() { + if (props.src != null && props.src !== '') { + nextTick() + .then(() => img.value?.decode()) + .then(() => { + loaded.value = true; + }, error => { + console.log('Error occurred during decoding image', img.value, error); + }); + } else { + loaded.value = false; + } +} + +watch([() => props.width, () => props.height, root], () => { + const ratio = props.width / props.height; + if (ratio > 1) { + canvasWidth.value = Math.round(64 * ratio); + canvasHeight.value = 64; + } else { + canvasWidth.value = 64; + canvasHeight.value = Math.round(64 / ratio); + } + + const clientWidth = root.value?.clientWidth ?? 300; + imgWidth.value = clientWidth; + imgHeight.value = Math.round(clientWidth / ratio); +}, { + immediate: true, +}); + +function drawImage(bitmap: CanvasImageSource) { + // canvasãŒãªã„(mountedã•ã‚Œã¦ã„ãªã„ï¼‰å ´åˆã¯Tmpã«ä¿å˜ã—ã¦ãŠã + if (!canvas.value) { + bitmapTmp.value = bitmap; + return; + } + + // canvasãŒã‚ã‚Œã°æç”»ã™ã‚‹ + bitmapTmp.value = undefined; + const ctx = canvas.value.getContext('2d'); + if (!ctx) return; + ctx.drawImage(bitmap, 0, 0, canvasWidth.value, canvasHeight.value); +} + +function drawAvg() { + if (!canvas.value) return; + + const color = (props.hash != null && extractAvgColorFromBlurhash(props.hash)) || '#888'; + + const ctx = canvas.value.getContext('2d'); + if (!ctx) return; + + // avgColorã§ãŠèŒ¶ã‚’ã«ã”ã™ + ctx.beginPath(); + ctx.fillStyle = color; + ctx.fillRect(0, 0, canvasWidth.value, canvasHeight.value); +} + +async function draw() { + if (import.meta.env.MODE === 'test' && props.hash == null) return; + + drawAvg(); + + if (props.hash == null) return; + + if (props.onlyAvgColor) return; + + const work = await canvasPromise; + if (work instanceof WorkerMultiDispatch) { + work.postMessage( + { + id: viewId, + hash: props.hash, + }, + undefined, + ); + } else { + try { + render(props.hash, work); + drawImage(work); + } catch (error) { + console.error('Error occurred during drawing blurhash', error); + } + } +} + +function workerOnMessage(event: MessageEvent) { + if (event.data.id !== viewId) return; + drawImage(event.data.bitmap as ImageBitmap); +} + +canvasPromise.then(work => { + if (work instanceof WorkerMultiDispatch) { + work.addListener(workerOnMessage); + } + + draw(); +}); + +watch(() => props.src, () => { + waitForDecode(); +}); + +watch(() => props.hash, () => { + draw(); +}); + +onMounted(() => { + // drawImageãŒmountedより先ã«å‘¼ã°ã‚Œã¦ã„ã‚‹å ´åˆã¯ã“ã“ã§æç”»ã™ã‚‹ + if (bitmapTmp.value) { + drawImage(bitmapTmp.value); + } + waitForDecode(); +}); + +onUnmounted(() => { + canvasPromise.then(work => { + if (work instanceof WorkerMultiDispatch) { + work.removeListener(workerOnMessage); + } + }); +}); +</script> + +<style lang="scss" module> +.root { + position: relative; + width: 100%; + height: 100%; + + &.cover { + > .canvas, + > .img { + object-fit: cover; + } + } +} + +.canvas, +.img { + display: block; + width: 100%; + height: 100%; +} + +.canvas { + object-fit: contain; +} + +.img { + object-fit: contain; +} +</style> diff --git a/packages/frontend-embed/src/components/EmInstanceTicker.vue b/packages/frontend-embed/src/components/EmInstanceTicker.vue new file mode 100644 index 0000000000000000000000000000000000000000..eeeaee528e75e2b3e61632adfbe8824b5e5ae667 --- /dev/null +++ b/packages/frontend-embed/src/components/EmInstanceTicker.vue @@ -0,0 +1,87 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.root" :style="bg"> + <img v-if="faviconUrl" :class="$style.icon" :src="faviconUrl"/> + <div :class="$style.name">{{ instance.name }}</div> +</div> +</template> + +<script lang="ts" setup> +import { computed, inject } from 'vue'; + +import { DI } from '@/di.js'; + +const serverMetadata = inject(DI.serverMetadata)!; +const mediaProxy = inject(DI.mediaProxy)!; + +const props = defineProps<{ + instance?: { + faviconUrl?: string | null + name?: string | null + themeColor?: string | null + } +}>(); + +// if no instance data is given, this is for the local instance +const instance = props.instance ?? { + name: serverMetadata.name, + themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement).content, +}; + +const faviconUrl = computed(() => props.instance ? mediaProxy.getProxiedImageUrlNullable(props.instance.faviconUrl, 'preview') : mediaProxy.getProxiedImageUrlNullable(serverMetadata.iconUrl, 'preview') ?? '/favicon.ico'); + +const themeColor = serverMetadata.themeColor ?? '#777777'; + +const bg = { + background: `linear-gradient(90deg, ${themeColor}, ${themeColor}00)`, +}; +</script> + +<style lang="scss" module> +$height: 2ex; + +.root { + display: flex; + align-items: center; + height: $height; + border-radius: 4px 0 0 4px; + overflow: clip; + color: #fff; + text-shadow: /* .866 ≈ sin(60deg) */ + 1px 0 1px #000, + .866px .5px 1px #000, + .5px .866px 1px #000, + 0 1px 1px #000, + -.5px .866px 1px #000, + -.866px .5px 1px #000, + -1px 0 1px #000, + -.866px -.5px 1px #000, + -.5px -.866px 1px #000, + 0 -1px 1px #000, + .5px -.866px 1px #000, + .866px -.5px 1px #000; + mask-image: linear-gradient(90deg, + rgb(0,0,0), + rgb(0,0,0) calc(100% - 16px), + rgba(0,0,0,0) 100% + ); +} + +.icon { + height: $height; + flex-shrink: 0; +} + +.name { + margin-left: 4px; + line-height: 1; + font-size: 0.9em; + font-weight: bold; + white-space: nowrap; + overflow: visible; +} +</style> diff --git a/packages/frontend-embed/src/components/EmLink.vue b/packages/frontend-embed/src/components/EmLink.vue new file mode 100644 index 0000000000000000000000000000000000000000..319ad723990efdec81a51ae6f831a206fc839a9f --- /dev/null +++ b/packages/frontend-embed/src/components/EmLink.vue @@ -0,0 +1,40 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<component + :is="self ? EmA : 'a'" ref="el" style="word-break: break-all;" class="_link" :[attr]="self ? url.substring(local.length) : url" :rel="rel ?? 'nofollow noopener'" :target="target" + :title="url" +> + <slot></slot> + <i v-if="target === '_blank'" class="ti ti-external-link" :class="$style.icon"></i> +</component> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import EmA from './EmA.vue'; +import { url as local } from '@/config.js'; + +const props = withDefaults(defineProps<{ + url: string; + rel?: null | string; +}>(), { +}); + +const self = props.url.startsWith(local); +const attr = self ? 'to' : 'href'; +const target = self ? null : '_blank'; + +const el = ref<HTMLElement | { $el: HTMLElement }>(); + +</script> + +<style lang="scss" module> +.icon { + padding-left: 2px; + font-size: .9em; +} +</style> diff --git a/packages/frontend-embed/src/components/EmLoading.vue b/packages/frontend-embed/src/components/EmLoading.vue new file mode 100644 index 0000000000000000000000000000000000000000..49d8ace37bee32d60c09783c03bfd738c12bbc03 --- /dev/null +++ b/packages/frontend-embed/src/components/EmLoading.vue @@ -0,0 +1,112 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="[$style.root, { [$style.inline]: inline, [$style.colored]: colored, [$style.mini]: mini, [$style.em]: em }]"> + <div :class="$style.container"> + <svg :class="[$style.spinner, $style.bg]" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg"> + <g transform="matrix(1.125,0,0,1.125,12,12)"> + <circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/> + </g> + </svg> + <svg :class="[$style.spinner, $style.fg, { [$style.static]: static }]" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg"> + <g transform="matrix(1.125,0,0,1.125,12,12)"> + <path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/> + </g> + </svg> + </div> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; + +const props = withDefaults(defineProps<{ + static?: boolean; + inline?: boolean; + colored?: boolean; + mini?: boolean; + em?: boolean; +}>(), { + static: false, + inline: false, + colored: true, + mini: false, + em: false, +}); +</script> + +<style lang="scss" module> +@keyframes spinner { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.root { + padding: 32px; + text-align: center; + cursor: wait; + + --size: 38px; + + &.colored { + color: var(--accent); + } + + &.inline { + display: inline; + padding: 0; + --size: 32px; + } + + &.mini { + padding: 16px; + --size: 32px; + } + + &.em { + display: inline-block; + vertical-align: middle; + padding: 0; + --size: 1em; + } +} + +.container { + position: relative; + width: var(--size); + height: var(--size); + margin: 0 auto; +} + +.spinner { + position: absolute; + top: 0; + left: 0; + width: var(--size); + height: var(--size); + fill-rule: evenodd; + clip-rule: evenodd; + stroke-linecap: round; + stroke-linejoin: round; + stroke-miterlimit: 1.5; +} + +.bg { + opacity: 0.275; +} + +.fg { + animation: spinner 0.5s linear infinite; + + &.static { + animation-play-state: paused; + } +} +</style> diff --git a/packages/frontend-embed/src/components/EmMediaBanner.vue b/packages/frontend-embed/src/components/EmMediaBanner.vue new file mode 100644 index 0000000000000000000000000000000000000000..435da238a4e689ae7ebf51fceee3869013472957 --- /dev/null +++ b/packages/frontend-embed/src/components/EmMediaBanner.vue @@ -0,0 +1,55 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<a :href="href" target="_blank" :class="$style.root"> + <div :class="$style.label"> + <template v-if="media.type.startsWith('audio')"><i class="ti ti-music"></i> {{ i18n.ts.audio }}</template> + <template v-else><i class="ti ti-file"></i> {{ i18n.ts.file }}</template> + </div> + <div :class="$style.go"> + <i class="ti ti-chevron-right"></i> + </div> +</a> +</template> + +<script setup lang="ts"> +import * as Misskey from 'misskey-js'; +import { i18n } from '@/i18n.js'; + +defineProps<{ + media: Misskey.entities.DriveFile; + href: string; +}>(); +</script> + +<style lang="scss" module> +.root { + box-sizing: border-box; + display: flex; + align-items: center; + width: 100%; + padding: var(--margin); + margin-top: 4px; + border: 1px solid var(--inputBorder); + border-radius: var(--radius); + background-color: var(--panel); + transition: background-color .1s, border-color .1s; + + &:hover { + text-decoration: none; + border-color: var(--inputBorderHover); + background-color: var(--buttonHoverBg); + } +} + +.label { + font-size: .9em; +} + +.go { + margin-left: auto; +} +</style> diff --git a/packages/frontend-embed/src/components/EmMediaImage.vue b/packages/frontend-embed/src/components/EmMediaImage.vue new file mode 100644 index 0000000000000000000000000000000000000000..fe1aa5a877be200678b0acf75ae89328385a75ff --- /dev/null +++ b/packages/frontend-embed/src/components/EmMediaImage.vue @@ -0,0 +1,154 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="[hide ? $style.hidden : $style.visible]" :style="darkMode ? '--c: rgb(255 255 255 / 2%);' : '--c: rgb(0 0 0 / 2%);'" @click="onclick"> + <a + :title="image.name" + :class="$style.imageContainer" + :href="href ?? image.url" + target="_blank" + rel="noopener" + > + <ImgWithBlurhash + :hash="image.blurhash" + :src="hide ? null : url" + :forceBlurhash="hide" + :cover="hide || cover" + :alt="image.comment || image.name" + :title="image.comment || image.name" + :width="image.properties.width" + :height="image.properties.height" + :style="hide ? 'filter: brightness(0.7);' : null" + /> + </a> + <template v-if="hide"> + <div :class="$style.hiddenText"> + <div :class="$style.hiddenTextWrapper"> + <b v-if="image.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}</b> + <b v-else style="display: block;"><i class="ti ti-photo"></i> {{ i18n.ts.image }}</b> + <span style="display: block;">{{ i18n.ts.clickToShow }}</span> + </div> + </div> + </template> + <div :class="$style.indicators"> + <div v-if="['image/gif', 'image/apng'].includes(image.type)" :class="$style.indicator">GIF</div> + <div v-if="image.comment" :class="$style.indicator">ALT</div> + <div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--warn);" :title="i18n.ts.sensitive"><i class="ti ti-eye-exclamation"></i></div> + </div> + <i v-if="!hide" class="ti ti-eye-off" :class="$style.hide" @click.stop="hide = true"></i> +</div> +</template> + +<script lang="ts" setup> +import { ref, computed } from 'vue'; +import * as Misskey from 'misskey-js'; +import ImgWithBlurhash from '@/components/EmImgWithBlurhash.vue'; +import { i18n } from '@/i18n.js'; + +const props = withDefaults(defineProps<{ + image: Misskey.entities.DriveFile; + href?: string; + raw?: boolean; + cover?: boolean; +}>(), { + cover: false, +}); + +const hide = ref(props.image.isSensitive); +const darkMode = ref<boolean>(false); // TODO + +const url = computed(() => (props.raw) + ? props.image.url + : props.image.thumbnailUrl, +); + +async function onclick(ev: MouseEvent) { + if (hide.value) { + ev.stopPropagation(); + hide.value = false; + } +} +</script> + +<style lang="scss" module> +.hidden { + position: relative; +} + +.hiddenText { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + z-index: 1; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; +} + +.hide { + display: block; + position: absolute; + border-radius: 6px; + background-color: var(--fg); + color: var(--accentLighten); + font-size: 12px; + opacity: .5; + padding: 5px 8px; + text-align: center; + cursor: pointer; + top: 12px; + right: 12px; +} + +.hiddenTextWrapper { + display: table-cell; + text-align: center; + font-size: 0.8em; + color: #fff; +} + +.visible { + position: relative; + //box-shadow: 0 0 0 1px var(--divider) inset; + background: var(--bg); + background-image: linear-gradient(45deg, var(--c) 16.67%, var(--bg) 16.67%, var(--bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--bg) 66.67%, var(--bg) 100%); + background-size: 16px 16px; +} + +.imageContainer { + display: block; + overflow: hidden; + width: 100%; + height: 100%; + background-position: center; + background-size: contain; + background-repeat: no-repeat; +} + +.indicators { + display: inline-flex; + position: absolute; + top: 10px; + left: 10px; + pointer-events: none; + opacity: .5; + gap: 6px; +} + +.indicator { + /* Hardcode to black because either --bg or --fg makes it hard to read in dark/light mode */ + background-color: black; + border-radius: 6px; + color: var(--accentLighten); + display: inline-block; + font-weight: bold; + font-size: 0.8em; + padding: 2px 5px; +} +</style> diff --git a/packages/frontend-embed/src/components/EmMediaList.vue b/packages/frontend-embed/src/components/EmMediaList.vue new file mode 100644 index 0000000000000000000000000000000000000000..0b2d835abe9ea2885ef623e2bb0f895ee32d183e --- /dev/null +++ b/packages/frontend-embed/src/components/EmMediaList.vue @@ -0,0 +1,146 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div> + <div v-for="media in mediaList.filter(media => !previewable(media))" :key="media.id" :class="$style.banner"> + <XBanner :media="media" :href="originalEntityUrl"/> + </div> + <div v-if="mediaList.filter(media => previewable(media)).length > 0" :class="$style.container"> + <div + :class="[ + $style.medias, + count === 1 ? [$style.n1] : count === 2 ? $style.n2 : count === 3 ? $style.n3 : count === 4 ? $style.n4 : $style.nMany, + ]" + > + <div v-for="media in mediaList.filter(media => previewable(media))" :class="$style.media"> + <XVideo v-if="media.type.startsWith('video')" :key="`video:${media.id}`" :class="$style.mediaInner" :video="media" :href="originalEntityUrl"/> + <XImage v-else-if="media.type.startsWith('image')" :key="`image:${media.id}`" :class="$style.mediaInner" class="image" :image="media" :raw="raw" :href="originalEntityUrl"/> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import * as Misskey from 'misskey-js'; +import XBanner from './EmMediaBanner.vue'; +import XImage from './EmMediaImage.vue'; +import XVideo from './EmMediaVideo.vue'; +import { FILE_TYPE_BROWSERSAFE } from '@@/js/const.js'; + +const props = defineProps<{ + mediaList: Misskey.entities.DriveFile[]; + raw?: boolean; + + /** 埋ã‚è¾¼ã¿ãƒšãƒ¼ã‚¸ç”¨ 親è¦ç´ ã®æ£è¦URL */ + originalEntityUrl: string; +}>(); + +const count = computed(() => props.mediaList.filter(media => previewable(media)).length); + +const previewable = (file: Misskey.entities.DriveFile): boolean => { + if (file.type === 'image/svg+xml') return true; // svgã®webpublic/thumbnailã¯pngãªã®ã§true + // FILE_TYPE_BROWSERSAFEã«é©åˆã—ãªã„ã‚‚ã®ã¯ãƒ–ラウザã§è¡¨ç¤ºã™ã‚‹ã®ã«ä¸é©åˆ‡ + return (file.type.startsWith('video') || file.type.startsWith('image')) && FILE_TYPE_BROWSERSAFE.includes(file.type); +}; +</script> + +<style lang="scss" module> +.container { + position: relative; + width: 100%; + margin-top: 4px; +} + +.medias { + display: grid; + grid-gap: 8px; + + height: 100%; + width: 100%; + + &.n1 { + grid-template-rows: 1fr; + + // default but fallback (expand) + min-height: 64px; + max-height: clamp( + 64px, + 50cqh, + min(360px, 50vh) + ); + + &.n116_9 { + min-height: initial; + max-height: initial; + aspect-ratio: 16 / 9; // fallback + } + + &.n11_1{ + min-height: initial; + max-height: initial; + aspect-ratio: 1 / 1; // fallback + } + + &.n12_3 { + min-height: initial; + max-height: initial; + aspect-ratio: 2 / 3; // fallback + } + } + + &.n2 { + aspect-ratio: 16/9; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr; + } + + &.n3 { + aspect-ratio: 16/9; + grid-template-columns: 1fr 0.5fr; + grid-template-rows: 1fr 1fr; + + > .media:nth-child(1) { + grid-row: 1 / 3; + } + + > .media:nth-child(3) { + grid-column: 2 / 3; + grid-row: 2 / 3; + } + } + + &.n4 { + aspect-ratio: 16/9; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + } + + &.nMany { + grid-template-columns: 1fr 1fr; + + > .media { + aspect-ratio: 16/9; + } + } +} + +.media { + overflow: hidden; // clipã«ã™ã‚‹ã¨ãƒã‚°ã‚‹ + border-radius: 8px; + position: relative; + + >.mediaInner { + width: 100%; + height: 100%; + } +} + +.banner { + position: relative; +} +</style> diff --git a/packages/frontend-embed/src/components/EmMediaVideo.vue b/packages/frontend-embed/src/components/EmMediaVideo.vue new file mode 100644 index 0000000000000000000000000000000000000000..ce751f9acdf83fd49c5d61a4ffcd97ff46acba25 --- /dev/null +++ b/packages/frontend-embed/src/components/EmMediaVideo.vue @@ -0,0 +1,64 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<a :href="href" target="_blank" :class="$style.root"> + <img v-if="!video.isSensitive && video.thumbnailUrl" :class="$style.thumbnail" :src="video.thumbnailUrl"> + <div :class="$style.videoOverlayPlayButton"><i class="ti ti-player-play-filled"></i></div> +</a> +</template> + +<script setup lang="ts"> +import * as Misskey from 'misskey-js'; + +defineProps<{ + video: Misskey.entities.DriveFile; + href: string; +}>(); +</script> + +<style lang="scss" module> +.root { + position: relative; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: auto; + aspect-ratio: 16 / 9; + padding: var(--margin); + border: 1px solid var(--divider); + border-radius: var(--radius); + background-color: #000; + + &:hover { + text-decoration: none; + } +} + +.thumbnail { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +.videoOverlayPlayButton { + background: var(--accent); + color: #fff; + padding: 1rem; + border-radius: 99rem; + + font-size: 1rem; + line-height: 1rem; + + &:focus-visible { + outline: none; + } +} +</style> diff --git a/packages/frontend-embed/src/components/EmMention.vue b/packages/frontend-embed/src/components/EmMention.vue new file mode 100644 index 0000000000000000000000000000000000000000..5eadf828c765bb59e52b9b67422b5b929b996d23 --- /dev/null +++ b/packages/frontend-embed/src/components/EmMention.vue @@ -0,0 +1,46 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkA v-user-preview="canonical" :class="[$style.root]" :to="url" :style="{ background: bgCss }"> + <span> + <span>@{{ username }}</span> + <span v-if="(host != localHost)" :class="$style.host">@{{ toUnicode(host) }}</span> + </span> +</MkA> +</template> + +<script lang="ts" setup> +import { toUnicode } from 'punycode'; +import { } from 'vue'; +import tinycolor from 'tinycolor2'; +import { host as localHost } from '@/config.js'; + +const props = defineProps<{ + username: string; + host: string; +}>(); + +const canonical = props.host === localHost ? `@${props.username}` : `@${props.username}@${toUnicode(props.host)}`; + +const url = `/${canonical}`; + +const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--mention')); +bg.setAlpha(0.1); +const bgCss = bg.toRgbString(); +</script> + +<style lang="scss" module> +.root { + display: inline-block; + padding: 4px 8px 4px 4px; + border-radius: 999px; + color: var(--mention); +} + +.host { + opacity: 0.5; +} +</style> diff --git a/packages/frontend-embed/src/components/EmMfm.ts b/packages/frontend-embed/src/components/EmMfm.ts new file mode 100644 index 0000000000000000000000000000000000000000..7543d3cd540d01691f7e86b5e8b6eb7850e2ec0a --- /dev/null +++ b/packages/frontend-embed/src/components/EmMfm.ts @@ -0,0 +1,461 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { VNode, h, SetupContext, provide } from 'vue'; +import * as mfm from 'mfm-js'; +import * as Misskey from 'misskey-js'; +import EmUrl from '@/components/EmUrl.vue'; +import EmTime from '@/components/EmTime.vue'; +import EmLink from '@/components/EmLink.vue'; +import EmMention from '@/components/EmMention.vue'; +import EmEmoji from '@/components/EmEmoji.vue'; +import EmCustomEmoji from '@/components/EmCustomEmoji.vue'; +import EmA from '@/components/EmA.vue'; +import { host } from '@/config.js'; + +function safeParseFloat(str: unknown): number | null { + if (typeof str !== 'string' || str === '') return null; + const num = parseFloat(str); + if (isNaN(num)) return null; + return num; +} + +const QUOTE_STYLE = ` +display: block; +margin: 8px; +padding: 6px 0 6px 12px; +color: var(--fg); +border-left: solid 3px var(--fg); +opacity: 0.7; +`.split('\n').join(' '); + +type MfmProps = { + text: string; + plain?: boolean; + nowrap?: boolean; + author?: Misskey.entities.UserLite; + isNote?: boolean; + emojiUrls?: Record<string, string>; + rootScale?: number; + nyaize?: boolean | 'respect'; + parsedNodes?: mfm.MfmNode[] | null; + enableEmojiMenu?: boolean; + enableEmojiMenuReaction?: boolean; + linkNavigationBehavior?: string; +}; + +type MfmEvents = { + clickEv(id: string): void; +}; + +// eslint-disable-next-line import/no-default-export +export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEvents>['emit'] }) { + provide('linkNavigationBehavior', props.linkNavigationBehavior); + + const isNote = props.isNote ?? true; + const shouldNyaize = props.nyaize ? props.nyaize === 'respect' ? props.author?.isCat : false : false; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (props.text == null || props.text === '') return; + + const rootAst = props.parsedNodes ?? (props.plain ? mfm.parseSimple : mfm.parse)(props.text); + + const validTime = (t: string | boolean | null | undefined) => { + if (t == null) return null; + if (typeof t === 'boolean') return null; + return t.match(/^\-?[0-9.]+s$/) ? t : null; + }; + + const validColor = (c: unknown): string | null => { + if (typeof c !== 'string') return null; + return c.match(/^[0-9a-f]{3,6}$/i) ? c : null; + }; + + const useAnim = true; + + /** + * Gen Vue Elements from MFM AST + * @param ast MFM AST + * @param scale How times large the text is + * @param disableNyaize Whether nyaize is disabled or not + */ + const genEl = (ast: mfm.MfmNode[], scale: number, disableNyaize = false) => ast.map((token): VNode | string | (VNode | string)[] => { + switch (token.type) { + case 'text': { + let text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n'); + if (!disableNyaize && shouldNyaize) { + text = Misskey.nyaize(text); + } + + if (!props.plain) { + const res: (VNode | string)[] = []; + for (const t of text.split('\n')) { + res.push(h('br')); + res.push(t); + } + res.shift(); + return res; + } else { + return [text.replace(/\n/g, ' ')]; + } + } + + case 'bold': { + return [h('b', genEl(token.children, scale))]; + } + + case 'strike': { + return [h('del', genEl(token.children, scale))]; + } + + case 'italic': { + return h('i', { + style: 'font-style: oblique;', + }, genEl(token.children, scale)); + } + + case 'fn': { + // TODO: CSSã‚’æ–‡å—列ã§çµ„ã¿ç«‹ã¦ã¦ã„ã㨠token.props.args.~~~ 経由ã§CSSインジェクションã§ãã‚‹ã®ã§ã‚ˆã—ãªã«ã‚„ã‚‹ + let style: string | undefined; + switch (token.props.name) { + case 'tada': { + const speed = validTime(token.props.args.speed) ?? '1s'; + const delay = validTime(token.props.args.delay) ?? '0s'; + style = 'font-size: 150%;' + (useAnim ? `animation: global-tada ${speed} linear infinite both; animation-delay: ${delay};` : ''); + break; + } + case 'jelly': { + const speed = validTime(token.props.args.speed) ?? '1s'; + const delay = validTime(token.props.args.delay) ?? '0s'; + style = (useAnim ? `animation: mfm-rubberBand ${speed} linear infinite both; animation-delay: ${delay};` : ''); + break; + } + case 'twitch': { + const speed = validTime(token.props.args.speed) ?? '0.5s'; + const delay = validTime(token.props.args.delay) ?? '0s'; + style = useAnim ? `animation: mfm-twitch ${speed} ease infinite; animation-delay: ${delay};` : ''; + break; + } + case 'shake': { + const speed = validTime(token.props.args.speed) ?? '0.5s'; + const delay = validTime(token.props.args.delay) ?? '0s'; + style = useAnim ? `animation: mfm-shake ${speed} ease infinite; animation-delay: ${delay};` : ''; + break; + } + case 'spin': { + const direction = + token.props.args.left ? 'reverse' : + token.props.args.alternate ? 'alternate' : + 'normal'; + const anime = + token.props.args.x ? 'mfm-spinX' : + token.props.args.y ? 'mfm-spinY' : + 'mfm-spin'; + const speed = validTime(token.props.args.speed) ?? '1.5s'; + const delay = validTime(token.props.args.delay) ?? '0s'; + style = useAnim ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction}; animation-delay: ${delay};` : ''; + break; + } + case 'jump': { + const speed = validTime(token.props.args.speed) ?? '0.75s'; + const delay = validTime(token.props.args.delay) ?? '0s'; + style = useAnim ? `animation: mfm-jump ${speed} linear infinite; animation-delay: ${delay};` : ''; + break; + } + case 'bounce': { + const speed = validTime(token.props.args.speed) ?? '0.75s'; + const delay = validTime(token.props.args.delay) ?? '0s'; + style = useAnim ? `animation: mfm-bounce ${speed} linear infinite; transform-origin: center bottom; animation-delay: ${delay};` : ''; + break; + } + case 'flip': { + const transform = + (token.props.args.h && token.props.args.v) ? 'scale(-1, -1)' : + token.props.args.v ? 'scaleY(-1)' : + 'scaleX(-1)'; + style = `transform: ${transform};`; + break; + } + case 'x2': { + return h('span', { + class: 'mfm-x2', + }, genEl(token.children, scale * 2)); + } + case 'x3': { + return h('span', { + class: 'mfm-x3', + }, genEl(token.children, scale * 3)); + } + case 'x4': { + return h('span', { + class: 'mfm-x4', + }, genEl(token.children, scale * 4)); + } + case 'font': { + const family = + token.props.args.serif ? 'serif' : + token.props.args.monospace ? 'monospace' : + token.props.args.cursive ? 'cursive' : + token.props.args.fantasy ? 'fantasy' : + token.props.args.emoji ? 'emoji' : + token.props.args.math ? 'math' : + null; + if (family) style = `font-family: ${family};`; + break; + } + case 'blur': { + return h('span', { + class: '_mfm_blur_', + }, genEl(token.children, scale)); + } + case 'rainbow': { + if (!useAnim) { + return h('span', { + class: '_mfm_rainbow_fallback_', + }, genEl(token.children, scale)); + } + const speed = validTime(token.props.args.speed) ?? '1s'; + const delay = validTime(token.props.args.delay) ?? '0s'; + style = `animation: mfm-rainbow ${speed} linear infinite; animation-delay: ${delay};`; + break; + } + case 'sparkle': { + return genEl(token.children, scale); + } + case 'rotate': { + const degrees = safeParseFloat(token.props.args.deg) ?? 90; + style = `transform: rotate(${degrees}deg); transform-origin: center center;`; + break; + } + case 'position': { + const x = safeParseFloat(token.props.args.x) ?? 0; + const y = safeParseFloat(token.props.args.y) ?? 0; + style = `transform: translateX(${x}em) translateY(${y}em);`; + break; + } + case 'scale': { + const x = Math.min(safeParseFloat(token.props.args.x) ?? 1, 5); + const y = Math.min(safeParseFloat(token.props.args.y) ?? 1, 5); + style = `transform: scale(${x}, ${y});`; + scale = scale * Math.max(x, y); + break; + } + case 'fg': { + let color = validColor(token.props.args.color); + color = color ?? 'f00'; + style = `color: #${color}; overflow-wrap: anywhere;`; + break; + } + case 'bg': { + let color = validColor(token.props.args.color); + color = color ?? 'f00'; + style = `background-color: #${color}; overflow-wrap: anywhere;`; + break; + } + case 'border': { + let color = validColor(token.props.args.color); + color = color ? `#${color}` : 'var(--accent)'; + let b_style = token.props.args.style; + if ( + typeof b_style !== 'string' || + !['hidden', 'dotted', 'dashed', 'solid', 'double', 'groove', 'ridge', 'inset', 'outset'] + .includes(b_style) + ) b_style = 'solid'; + const width = safeParseFloat(token.props.args.width) ?? 1; + const radius = safeParseFloat(token.props.args.radius) ?? 0; + style = `border: ${width}px ${b_style} ${color}; border-radius: ${radius}px;${token.props.args.noclip ? '' : ' overflow: clip;'}`; + break; + } + case 'ruby': { + if (token.children.length === 1) { + const child = token.children[0]; + let text = child.type === 'text' ? child.props.text : ''; + if (!disableNyaize && shouldNyaize) { + text = Misskey.nyaize(text); + } + return h('ruby', {}, [text.split(' ')[0], h('rt', text.split(' ')[1])]); + } else { + const rt = token.children.at(-1)!; + let text = rt.type === 'text' ? rt.props.text : ''; + if (!disableNyaize && shouldNyaize) { + text = Misskey.nyaize(text); + } + return h('ruby', {}, [...genEl(token.children.slice(0, token.children.length - 1), scale), h('rt', text.trim())]); + } + } + case 'unixtime': { + const child = token.children[0]; + const unixtime = parseInt(child.type === 'text' ? child.props.text : ''); + return h('span', { + style: 'display: inline-block; font-size: 90%; border: solid 1px var(--divider); border-radius: 999px; padding: 4px 10px 4px 6px;', + }, [ + h('i', { + class: 'ti ti-clock', + style: 'margin-right: 0.25em;', + }), + h(EmTime, { + key: Math.random(), + time: unixtime * 1000, + mode: 'detail', + }), + ]); + } + case 'clickable': { + return h('span', { onClick(ev: MouseEvent): void { + ev.stopPropagation(); + ev.preventDefault(); + const clickEv = typeof token.props.args.ev === 'string' ? token.props.args.ev : ''; + emit('clickEv', clickEv); + } }, genEl(token.children, scale)); + } + } + if (style === undefined) { + return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children, scale), ']']); + } else { + return h('span', { + style: 'display: inline-block; ' + style, + }, genEl(token.children, scale)); + } + } + + case 'small': { + return [h('small', { + style: 'opacity: 0.7;', + }, genEl(token.children, scale))]; + } + + case 'center': { + return [h('div', { + style: 'text-align:center;', + }, genEl(token.children, scale))]; + } + + case 'url': { + return [h(EmUrl, { + key: Math.random(), + url: token.props.url, + rel: 'nofollow noopener', + })]; + } + + case 'link': { + return [h(EmLink, { + key: Math.random(), + url: token.props.url, + rel: 'nofollow noopener', + }, genEl(token.children, scale, true))]; + } + + case 'mention': { + return [h(EmMention, { + key: Math.random(), + host: (token.props.host == null && props.author && props.author.host != null ? props.author.host : token.props.host) ?? host, + username: token.props.username, + })]; + } + + case 'hashtag': { + return [h(EmA, { + key: Math.random(), + to: isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/user-tags/${encodeURIComponent(token.props.hashtag)}`, + style: 'color:var(--hashtag);', + }, `#${token.props.hashtag}`)]; + } + + case 'blockCode': { + return [h('code', { + key: Math.random(), + lang: token.props.lang ?? undefined, + }, token.props.code)]; + } + + case 'inlineCode': { + return [h('code', { + key: Math.random(), + }, token.props.code)]; + } + + case 'quote': { + if (!props.nowrap) { + return [h('div', { + style: QUOTE_STYLE, + }, genEl(token.children, scale, true))]; + } else { + return [h('span', { + style: QUOTE_STYLE, + }, genEl(token.children, scale, true))]; + } + } + + case 'emojiCode': { + if (props.author?.host == null) { + return [h(EmCustomEmoji, { + key: Math.random(), + name: token.props.name, + normal: props.plain, + host: null, + useOriginalSize: scale >= 2.5, + menu: props.enableEmojiMenu, + menuReaction: props.enableEmojiMenuReaction, + fallbackToImage: false, + })]; + } else { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (props.emojiUrls && (props.emojiUrls[token.props.name] == null)) { + return [h('span', `:${token.props.name}:`)]; + } else { + return [h(EmCustomEmoji, { + key: Math.random(), + name: token.props.name, + url: props.emojiUrls && props.emojiUrls[token.props.name], + normal: props.plain, + host: props.author.host, + useOriginalSize: scale >= 2.5, + })]; + } + } + } + + case 'unicodeEmoji': { + return [h(EmEmoji, { + key: Math.random(), + emoji: token.props.emoji, + menu: props.enableEmojiMenu, + menuReaction: props.enableEmojiMenuReaction, + })]; + } + + case 'mathInline': { + return [h('code', token.props.formula)]; + } + + case 'mathBlock': { + return [h('code', token.props.formula)]; + } + + case 'search': { + return [h('div', { + key: Math.random(), + }, token.props.query)]; + } + + case 'plain': { + return [h('span', genEl(token.children, scale, true))]; + } + + default: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + console.error('unrecognized ast type:', (token as any).type); + + return []; + } + } + }).flat(Infinity) as (VNode | string)[]; + + return h('span', { + // https://codeday.me/jp/qa/20190424/690106.html + style: props.nowrap ? 'white-space: pre; word-wrap: normal; overflow: hidden; text-overflow: ellipsis;' : 'white-space: pre-wrap;', + }, genEl(rootAst, props.rootScale ?? 1)); +} diff --git a/packages/frontend-embed/src/components/EmNote.vue b/packages/frontend-embed/src/components/EmNote.vue new file mode 100644 index 0000000000000000000000000000000000000000..7c4d5910660bf0e47c476a365c74d66295d9c5a5 --- /dev/null +++ b/packages/frontend-embed/src/components/EmNote.vue @@ -0,0 +1,609 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + v-show="!isDeleted" + ref="rootEl" + :class="[$style.root]" + :tabindex="isDeleted ? '-1' : '0'" +> + <EmNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo"/> + <div v-if="pinned" :class="$style.tip"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div> + <!--<div v-if="appearNote._prId_" class="tip"><i class="ti ti-speakerphone"></i> {{ i18n.ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ i18n.ts.hideThisNote }} <i class="ti ti-x"></i></button></div>--> + <!--<div v-if="appearNote._featuredId_" class="tip"><i class="ti ti-bolt"></i> {{ i18n.ts.featured }}</div>--> + <div v-if="isRenote" :class="$style.renote"> + <div v-if="note.channel" :class="$style.colorBar" :style="{ background: note.channel.color }"></div> + <EmAvatar :class="$style.renoteAvatar" :user="note.user" link/> + <i class="ti ti-repeat" style="margin-right: 4px;"></i> + <I18n :src="i18n.ts.renotedBy" tag="span" :class="$style.renoteText"> + <template #user> + <EmA v-user-preview="true ? undefined : note.userId" :class="$style.renoteUserName" :to="userPage(note.user)"> + <EmUserName :user="note.user"/> + </EmA> + </template> + </I18n> + <div :class="$style.renoteInfo"> + <button ref="renoteTime" :class="$style.renoteTime" class="_button"> + <i class="ti ti-dots" :class="$style.renoteMenu"></i> + <EmTime :time="note.createdAt"/> + </button> + <span v-if="note.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[note.visibility]"> + <i v-if="note.visibility === 'home'" class="ti ti-home"></i> + <i v-else-if="note.visibility === 'followers'" class="ti ti-lock"></i> + <i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i> + </span> + <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span> + <span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ti ti-device-tv"></i></span> + </div> + </div> + <article :class="$style.article"> + <div v-if="appearNote.channel" :class="$style.colorBar" :style="{ background: appearNote.channel.color }"></div> + <EmAvatar :class="$style.avatar" :user="appearNote.user" link/> + <div :class="$style.main"> + <EmNoteHeader :note="appearNote" :mini="true"/> + <div style="container-type: inline-size;"> + <p v-if="appearNote.cw != null" :class="$style.cw"> + <EmMfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/> + <button style="display: block; width: 100%; margin: 4px 0;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button> + </p> + <div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]"> + <div :class="$style.text"> + <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> + <EmA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></EmA> + <EmMfm + v-if="appearNote.text" + :parsedNodes="parsed" + :text="appearNote.text" + :author="appearNote.user" + :nyaize="'respect'" + :emojiUrls="appearNote.emojis" + :enableEmojiMenu="!true" + :enableEmojiMenuReaction="true" + /> + </div> + <div v-if="appearNote.files && appearNote.files.length > 0"> + <EmMediaList :mediaList="appearNote.files" :originalEntityUrl="`${url}/notes/${appearNote.id}`"/> + </div> + <EmPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :readOnly="true" :class="$style.poll"/> + <div v-if="appearNote.renote" :class="$style.quote"><EmNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div> + <button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click="collapsed = false"> + <span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span> + </button> + <button v-else-if="isLong && !collapsed" :class="$style.showLess" class="_button" @click="collapsed = true"> + <span :class="$style.showLessLabel">{{ i18n.ts.showLess }}</span> + </button> + </div> + <EmA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</EmA> + </div> + <EmReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" :note="appearNote" :maxNumber="16"> + <template #more> + <EmA :to="`/notes/${appearNote.id}/reactions`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</EmA> + </template> + </EmReactionsViewer> + <footer :class="$style.footer"> + <a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.footerButton, $style.footerButtonLink]" class="_button"> + <i class="ti ti-arrow-back-up"></i> + </a> + <a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.footerButton, $style.footerButtonLink]" class="_button"> + <i class="ti ti-repeat"></i> + </a> + <a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.footerButton, $style.footerButtonLink]" class="_button"> + <i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i> + <i v-else class="ti ti-plus"></i> + </a> + <a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.footerButton, $style.footerButtonLink]" class="_button"> + <i class="ti ti-dots"></i> + </a> + </footer> + </div> + </article> +</div> +</template> + +<script lang="ts" setup> +import { computed, inject, onMounted, ref, shallowRef, Ref, watch, provide } from 'vue'; +import * as mfm from 'mfm-js'; +import * as Misskey from 'misskey-js'; +import I18n from '@/components/I18n.vue'; +import EmNoteSub from '@/components/EmNoteSub.vue'; +import EmNoteHeader from '@/components/EmNoteHeader.vue'; +import EmNoteSimple from '@/components/EmNoteSimple.vue'; +import EmReactionsViewer from '@/components/EmReactionsViewer.vue'; +import EmMediaList from '@/components/EmMediaList.vue'; +import EmPoll from '@/components/EmPoll.vue'; +import EmMfm from '@/components/EmMfm.js'; +import EmA from '@/components/EmA.vue'; +import EmAvatar from '@/components/EmAvatar.vue'; +import EmUserName from '@/components/EmUserName.vue'; +import EmTime from '@/components/EmTime.vue'; +import { userPage } from '@/utils.js'; +import { i18n } from '@/i18n.js'; +import { shouldCollapsed } from '@/to-be-shared/collapsed.js'; +import { url } from '@/config.js'; + +function getAppearNote(note: Misskey.entities.Note) { + return Misskey.note.isPureRenote(note) ? note.renote : note; +} + +const props = withDefaults(defineProps<{ + note: Misskey.entities.Note; + pinned?: boolean; +}>(), { +}); + +const emit = defineEmits<{ + (ev: 'reaction', emoji: string): void; + (ev: 'removeReaction', emoji: string): void; +}>(); + +const inChannel = inject('inChannel', null); + +const note = ref((props.note)); + +const isRenote = Misskey.note.isPureRenote(note.value); + +const rootEl = shallowRef<HTMLElement>(); +const renoteTime = shallowRef<HTMLElement>(); +const appearNote = computed(() => getAppearNote(note.value)); +const showContent = ref(false); +const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null); +const isLong = shouldCollapsed(appearNote.value, []); +const collapsed = ref(appearNote.value.cw == null && isLong); +const isDeleted = ref(false); +</script> + +<style lang="scss" module> +.root { + position: relative; + transition: box-shadow 0.1s ease; + font-size: 1.05em; + overflow: clip; + contain: content; + + // ã“れらã®æŒ‡å®šã¯ãƒ‘フォーマンスå‘上ã«ã¯æœ‰åŠ¹ã ãŒã€ãƒŽãƒ¼ãƒˆã®é«˜ã•ã¯ä¸€å®šã§ãªã„ãŸã‚〠+ // 下ã®æ–¹ã¾ã§ã‚¹ã‚¯ãƒãƒ¼ãƒ«ã™ã‚‹ã¨ä¸Šã®ãƒŽãƒ¼ãƒˆã®é«˜ã•ãŒã“ã“ã§æ±ºã‚打ã¡ã•ã‚ŒãŸã‚‚ã®ã«å¤‰åŒ–ã—ã€è¡¨ç¤ºã—ã¦ã„るノートã®ä½ç½®ãŒå¤‰ã‚ã£ã¦ã—ã¾ã† + // ノートãŒãƒžã‚¦ãƒ³ãƒˆã•ã‚ŒãŸã¨ãã«è‡ªèº«ã®é«˜ã•ã‚’å–å¾—ã— contain-intrinsic-size ã‚’è¨å®šã—ãªãŠã›ã°ã»ã¼è§£æ±ºã§ããã†ã ãŒã€ + // 今度ã¯ãã®å‡¦ç†è‡ªä½“ãŒãƒ‘フォーマンス低下ã®åŽŸå› ã«ãªã‚‰ãªã„ã‹æ‡¸å¿µã•ã‚Œã‚‹ã€‚ã¾ãŸã€è¢«ãƒªã‚¢ã‚¯ã‚·ãƒ§ãƒ³ã§ã‚‚高ã•ã¯å¤‰åŒ–ã™ã‚‹ãŸã‚ã€ã‚„ã¯ã‚Šå¤šå°‘ã®ã‚ºãƒ¬ã¯ç”Ÿã˜ã‚‹ + // 一度レンダリングã•ã‚ŒãŸè¦ç´ ã¯ãƒ–ラウザãŒã‚ˆã—ãªã«ã‚µã‚¤ã‚ºã‚’覚ãˆã¦ãŠã„ã¦ãれるよã†ãªå®Ÿè£…ã«ãªã‚‹ã¾ã§å¾…ã£ãŸæ–¹ãŒè‰¯ã•ãã†(ãªã‚‹ã®ã‹ï¼Ÿ) + //content-visibility: auto; + //contain-intrinsic-size: 0 128px; + + &:focus-visible { + outline: none; + + &::after { + content: ""; + pointer-events: none; + display: block; + position: absolute; + z-index: 10; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: calc(100% - 8px); + height: calc(100% - 8px); + border: dashed 2px var(--focus); + border-radius: var(--radius); + box-sizing: border-box; + } + } + + .footer { + position: relative; + z-index: 1; + } + + &:hover > .article > .main > .footer > .footerButton { + opacity: 1; + } + + &.showActionsOnlyHover { + .footer { + visibility: hidden; + position: absolute; + top: 12px; + right: 12px; + padding: 0 4px; + margin-bottom: 0 !important; + background: var(--popup); + border-radius: 8px; + box-shadow: 0px 4px 32px var(--shadow); + } + + .footerButton { + font-size: 90%; + + &:not(:last-child) { + margin-right: 0; + } + } + } + + &.showActionsOnlyHover:hover { + .footer { + visibility: visible; + } + } +} + +.tip { + display: flex; + align-items: center; + padding: 16px 32px 8px 32px; + line-height: 24px; + font-size: 90%; + white-space: pre; + color: #d28a3f; +} + +.tip + .article { + padding-top: 8px; +} + +.replyTo { + opacity: 0.7; + padding-bottom: 0; +} + +.renote { + position: relative; + display: flex; + align-items: center; + padding: 16px 32px 8px 32px; + line-height: 28px; + white-space: pre; + color: var(--renote); + + & + .article { + padding-top: 8px; + } + + > .colorBar { + height: calc(100% - 6px); + } +} + +.renoteAvatar { + flex-shrink: 0; + display: inline-block; + width: 28px; + height: 28px; + margin: 0 8px 0 0; +} + +.renoteText { + overflow: hidden; + flex-shrink: 1; + text-overflow: ellipsis; + white-space: nowrap; +} + +.renoteUserName { + font-weight: bold; +} + +.renoteInfo { + margin-left: auto; + font-size: 0.9em; +} + +.renoteTime { + flex-shrink: 0; + color: inherit; +} + +.renoteMenu { + margin-right: 4px; +} + +.collapsedRenoteTarget { + display: flex; + align-items: center; + line-height: 28px; + white-space: pre; + padding: 0 32px 18px; +} + +.collapsedRenoteTargetAvatar { + flex-shrink: 0; + display: inline-block; + width: 28px; + height: 28px; + margin: 0 8px 0 0; +} + +.collapsedRenoteTargetText { + overflow: hidden; + flex-shrink: 1; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 90%; + opacity: 0.7; + cursor: pointer; + + &:hover { + text-decoration: underline; + } +} + +.article { + position: relative; + display: flex; + padding: 28px 32px; +} + +.colorBar { + position: absolute; + top: 8px; + left: 8px; + width: 5px; + height: calc(100% - 16px); + border-radius: 999px; + pointer-events: none; +} + +.avatar { + flex-shrink: 0; + display: block !important; + margin: 0 14px 0 0; + width: 58px; + height: 58px; + position: sticky !important; + top: calc(22px + var(--stickyTop, 0px)); + left: 0; +} + +.main { + flex: 1; + min-width: 0; +} + +.cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; +} + +.showLess { + width: 100%; + margin-top: 14px; + position: sticky; + bottom: calc(var(--stickyBottom, 0px) + 14px); +} + +.showLessLabel { + display: inline-block; + background: var(--popup); + padding: 6px 10px; + font-size: 0.8em; + border-radius: 999px; + box-shadow: 0 2px 6px rgb(0 0 0 / 20%); +} + +.contentCollapsed { + position: relative; + max-height: 9em; + overflow: clip; +} + +.collapsed { + display: block; + position: absolute; + bottom: 0; + left: 0; + z-index: 2; + width: 100%; + height: 64px; + background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0)); + + &:hover > .collapsedLabel { + background: var(--panelHighlight); + } +} + +.collapsedLabel { + display: inline-block; + background: var(--panel); + padding: 6px 10px; + font-size: 0.8em; + border-radius: 999px; + box-shadow: 0 2px 6px rgb(0 0 0 / 20%); +} + +.text { + overflow-wrap: break-word; +} + +.replyIcon { + color: var(--accent); + margin-right: 0.5em; +} + +.translation { + border: solid 0.5px var(--divider); + border-radius: var(--radius); + padding: 12px; + margin-top: 8px; +} + +.urlPreview { + margin-top: 8px; +} + +.poll { + font-size: 80%; +} + +.quote { + padding: 8px 0; +} + +.quoteNote { + padding: 16px; + border: dashed 1px var(--renote); + border-radius: 8px; + overflow: clip; +} + +.channel { + opacity: 0.7; + font-size: 80%; +} + +.footer { + margin-bottom: -14px; +} + +.footerButton { + margin: 0; + padding: 8px; + opacity: 0.7; + + &:not(:last-child) { + margin-right: 28px; + } + + &:hover { + color: var(--fgHighlighted); + } +} + +.footerButtonLink:hover, +.footerButtonLink:focus, +.footerButtonLink:active { + text-decoration: none; +} + +.footerButtonCount { + display: inline; + margin: 0 0 0 8px; + opacity: 0.7; +} + +@container (max-width: 580px) { + .root { + font-size: 0.95em; + } + + .renote { + padding: 12px 26px 0 26px; + } + + .article { + padding: 24px 26px; + } + + .avatar { + width: 50px; + height: 50px; + } +} + +@container (max-width: 500px) { + .root { + font-size: 0.9em; + } + + .renote { + padding: 10px 22px 0 22px; + } + + .article { + padding: 20px 22px; + } + + .footer { + margin-bottom: -8px; + } +} + +@container (max-width: 480px) { + .renote { + padding: 8px 16px 0 16px; + } + + .tip { + padding: 8px 16px 0 16px; + } + + .collapsedRenoteTarget { + padding: 0 16px 9px; + margin-top: 4px; + } + + .article { + padding: 14px 16px; + } +} + +@container (max-width: 450px) { + .avatar { + margin: 0 10px 0 0; + width: 46px; + height: 46px; + top: calc(14px + var(--stickyTop, 0px)); + } +} + +@container (max-width: 400px) { + .root:not(.showActionsOnlyHover) { + .footerButton { + &:not(:last-child) { + margin-right: 18px; + } + } + } +} + +@container (max-width: 350px) { + .root:not(.showActionsOnlyHover) { + .footerButton { + &:not(:last-child) { + margin-right: 12px; + } + } + } + + .colorBar { + top: 6px; + left: 6px; + width: 4px; + height: calc(100% - 12px); + } +} + +@container (max-width: 300px) { + .avatar { + width: 44px; + height: 44px; + } + + .root:not(.showActionsOnlyHover) { + .footerButton { + &:not(:last-child) { + margin-right: 8px; + } + } + } +} + +@container (max-width: 250px) { + .quoteNote { + padding: 12px; + } +} + +.reactionOmitted { + display: inline-block; + margin-left: 8px; + opacity: .8; + font-size: 95%; +} +</style> diff --git a/packages/frontend-embed/src/components/EmNoteDetailed.vue b/packages/frontend-embed/src/components/EmNoteDetailed.vue new file mode 100644 index 0000000000000000000000000000000000000000..74a26856c84c0b623b124ffca4c188eab988a578 --- /dev/null +++ b/packages/frontend-embed/src/components/EmNoteDetailed.vue @@ -0,0 +1,486 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + v-show="!isDeleted" + ref="rootEl" + :class="$style.root" +> + <EmNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo"/> + <div v-if="isRenote" :class="$style.renote"> + <EmAvatar :class="$style.renoteAvatar" :user="note.user" link/> + <i class="ti ti-repeat" style="margin-right: 4px;"></i> + <span :class="$style.renoteText"> + <I18n :src="i18n.ts.renotedBy" tag="span"> + <template #user> + <EmA :class="$style.renoteName" :to="userPage(note.user)"> + <EmUserName :user="note.user"/> + </EmA> + </template> + </I18n> + </span> + <div :class="$style.renoteInfo"> + <div class="$style.renoteTime"> + <EmTime :time="note.createdAt"/> + </div> + <span v-if="note.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[note.visibility]"> + <i v-if="note.visibility === 'home'" class="ti ti-home"></i> + <i v-else-if="note.visibility === 'followers'" class="ti ti-lock"></i> + <i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i> + </span> + <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span> + </div> + </div> + <article :class="$style.note"> + <header :class="$style.noteHeader"> + <EmAvatar :class="$style.noteHeaderAvatar" :user="appearNote.user" indicator link/> + <div :class="$style.noteHeaderBody"> + <div :class="$style.noteHeaderBodyUpper"> + <div style="min-width: 0;"> + <div class="_nowrap"> + <EmA :class="$style.noteHeaderName" :to="userPage(appearNote.user)"> + <EmUserName :nowrap="true" :user="appearNote.user"/> + </EmA> + <span v-if="appearNote.user.isBot" :class="$style.isBot">bot</span> + </div> + <div :class="$style.noteHeaderUsername"><EmAcct :user="appearNote.user"/></div> + </div> + <div :class="$style.noteHeaderInfo"> + <a :href="url" :class="$style.noteHeaderInstanceIconLink" target="_blank" rel="noopener noreferrer"> + <img :src="serverMetadata.iconUrl || '/favicon.ico'" alt="" :class="$style.noteHeaderInstanceIcon"/> + </a> + </div> + </div> + </div> + </header> + <div :class="[$style.noteContent, { [$style.contentCollapsed]: collapsed }]"> + <p v-if="appearNote.cw != null" :class="$style.cw"> + <EmMfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/> + <button style="display: block; width: 100%; margin: 4px 0;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button> + </p> + <div v-show="appearNote.cw == null || showContent"> + <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> + <EmA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></EmA> + <EmMfm + v-if="appearNote.text" + :parsedNodes="parsed" + :text="appearNote.text" + :author="appearNote.user" + :nyaize="'respect'" + :emojiUrls="appearNote.emojis" + /> + <a v-if="appearNote.renote != null" :class="$style.rn">RN:</a> + <div v-if="appearNote.files && appearNote.files.length > 0"> + <EmMediaList :mediaList="appearNote.files" :originalEntityUrl="`${url}/notes/${appearNote.id}`"/> + </div> + <EmPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :readOnly="true" :class="$style.poll"/> + <div v-if="appearNote.renote" :class="$style.quote"><EmNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div> + <button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click="collapsed = false"> + <span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span> + </button> + <button v-else-if="isLong && !collapsed" :class="$style.showLess" class="_button" @click="collapsed = true"> + <span :class="$style.showLessLabel">{{ i18n.ts.showLess }}</span> + </button> + </div> + <EmA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</EmA> + </div> + <footer> + <div :class="$style.noteFooterInfo"> + <span v-if="appearNote.visibility !== 'public'" style="display: inline-block; margin-right: 0.5em;" :title="i18n.ts._visibility[appearNote.visibility]"> + <i v-if="appearNote.visibility === 'home'" class="ti ti-home"></i> + <i v-else-if="appearNote.visibility === 'followers'" class="ti ti-lock"></i> + <i v-else-if="appearNote.visibility === 'specified'" ref="specified" class="ti ti-mail"></i> + </span> + <span v-if="appearNote.localOnly" style="display: inline-block; margin-right: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span> + <EmA :to="notePage(appearNote)"> + <EmTime :time="appearNote.createdAt" mode="detail" colored/> + </EmA> + </div> + <EmReactionsViewer v-if="appearNote.reactionAcceptance !== 'likeOnly'" ref="reactionsViewer" :maxNumber="16" :note="appearNote"> + <template #more> + <EmA :to="`/notes/${appearNote.id}`" :class="[$style.reactionOmitted]">{{ i18n.ts.more }}</EmA> + </template> + </EmReactionsViewer> + <a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.noteFooterButton, $style.footerButtonLink]" class="_button"> + <i class="ti ti-arrow-back-up"></i> + </a> + <a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.noteFooterButton, $style.footerButtonLink]" class="_button"> + <i class="ti ti-repeat"></i> + <p v-if="appearNote.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ (appearNote.renoteCount) }}</p> + </a> + <a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.noteFooterButton, $style.footerButtonLink]" class="_button"> + <i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i> + <i v-else class="ti ti-plus"></i> + <p v-if="(appearNote.reactionAcceptance === 'likeOnly') && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ (appearNote.reactionCount) }}</p> + </a> + <a :href="`/notes/${appearNote.id}`" target="_blank" rel="noopener" :class="[$style.noteFooterButton, $style.footerButtonLink]" class="_button"> + <i class="ti ti-dots"></i> + </a> + </footer> + </article> +</div> +</template> + +<script lang="ts" setup> +import { computed, inject, ref } from 'vue'; +import * as mfm from 'mfm-js'; +import * as Misskey from 'misskey-js'; +import I18n from '@/components/I18n.vue'; +import EmMediaList from '@/components/EmMediaList.vue'; +import EmNoteSub from '@/components/EmNoteSub.vue'; +import EmNoteSimple from '@/components/EmNoteSimple.vue'; +import EmReactionsViewer from '@/components/EmReactionsViewer.vue'; +import EmPoll from '@/components/EmPoll.vue'; +import EmA from '@/components/EmA.vue'; +import EmAvatar from '@/components/EmAvatar.vue'; +import EmTime from '@/components/EmTime.vue'; +import EmUserName from '@/components/EmUserName.vue'; +import EmAcct from '@/components/EmAcct.vue'; +import { userPage } from '@/utils.js'; +import { notePage } from '@/utils.js'; +import { i18n } from '@/i18n.js'; +import { shouldCollapsed } from '@/to-be-shared/collapsed.js'; +import { serverMetadata } from '@/server-metadata.js'; +import { url } from '@/config.js'; +import EmMfm from '@/components/EmMfm.js'; + +const props = defineProps<{ + note: Misskey.entities.Note; +}>(); + +const inChannel = inject('inChannel', null); + +const note = ref(props.note); + +const isRenote = ( + note.value.renote != null && + note.value.reply == null && + note.value.text == null && + note.value.cw == null && + note.value.fileIds && note.value.fileIds.length === 0 && + note.value.poll == null +); + +const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value); +const showContent = ref(false); +const isDeleted = ref(false); +const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null; +const isLong = shouldCollapsed(appearNote.value, []); +const collapsed = ref(appearNote.value.cw == null && isLong); +</script> + +<style lang="scss" module> +.root { + position: relative; + transition: box-shadow 0.1s ease; + overflow: clip; + contain: content; +} + +.replyTo { + opacity: 0.7; + padding-bottom: 0; +} + +.renote { + display: flex; + align-items: center; + padding: 16px 32px 8px 32px; + line-height: 28px; + white-space: pre; + color: var(--renote); +} + +.renoteAvatar { + flex-shrink: 0; + display: inline-block; + width: 28px; + height: 28px; + margin: 0 8px 0 0; + border-radius: 6px; +} + +.renoteText { + overflow: hidden; + flex-shrink: 1; + text-overflow: ellipsis; + white-space: nowrap; +} + +.renoteName { + font-weight: bold; +} + +.renoteInfo { + margin-left: auto; + font-size: 0.9em; +} + +.renoteTime { + flex-shrink: 0; + color: inherit; +} + +.renote + .note { + padding-top: 8px; +} + +.note { + padding: 24px 32px 16px; + font-size: 1.2em; + + &:hover > .main > .footer > .button { + opacity: 1; + } +} + +.noteHeader { + display: flex; + position: relative; + margin-bottom: 16px; + align-items: center; +} + +.noteHeaderAvatar { + display: block; + flex-shrink: 0; + width: 50px; + height: 50px; +} + +.noteHeaderBody { + flex: 1; + display: flex; + min-width: 0; + flex-direction: column; + justify-content: center; + padding-left: 16px; + font-size: 0.95em; +} + +.noteHeaderBodyUpper { + display: flex; + min-width: 0; +} + +.noteHeaderName { + font-weight: bold; + line-height: 1.3; +} + +.isBot { + display: inline-block; + margin: 0 0.5em; + padding: 4px 6px; + font-size: 80%; + line-height: 1; + border: solid 0.5px var(--divider); + border-radius: 4px; +} + +.noteHeaderInfo { + margin-left: auto; + display: flex; + gap: 0.5em; + align-items: center; +} + +.noteHeaderInstanceIconLink { + display: inline-block; + margin-left: 4px; +} + +.noteHeaderInstanceIcon { + width: 32px; + height: 32px; + border-radius: 4px; +} + +.noteHeaderUsername { + margin-bottom: 2px; + line-height: 1.3; + word-wrap: anywhere; +} + +.noteContent { + container-type: inline-size; + overflow-wrap: break-word; +} + +.cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; +} + +.noteReplyTarget { + color: var(--accent); + margin-right: 0.5em; +} + +.rn { + margin-left: 4px; + font-style: oblique; + color: var(--renote); +} + +.reactionOmitted { + display: inline-block; + margin-left: 8px; + opacity: .8; + font-size: 95%; +} + +.poll { + font-size: 80%; +} + +.quote { + padding: 8px 0; +} + +.quoteNote { + padding: 16px; + border: dashed 1px var(--renote); + border-radius: 8px; + overflow: clip; +} + +.channel { + opacity: 0.7; + font-size: 80%; +} + +.showLess { + width: 100%; + margin-top: 14px; + position: sticky; + bottom: calc(var(--stickyBottom, 0px) + 14px); +} + +.showLessLabel { + display: inline-block; + background: var(--popup); + padding: 6px 10px; + font-size: 0.8em; + border-radius: 999px; + box-shadow: 0 2px 6px rgb(0 0 0 / 20%); +} + +.contentCollapsed { + position: relative; + max-height: 9em; + overflow: clip; +} + +.collapsed { + display: block; + position: absolute; + bottom: 0; + left: 0; + z-index: 2; + width: 100%; + height: 64px; + background: linear-gradient(0deg, var(--panel), var(--X15)); + + &:hover > .collapsedLabel { + background: var(--panelHighlight); + } +} + +.collapsedLabel { + display: inline-block; + background: var(--panel); + padding: 6px 10px; + font-size: 0.8em; + border-radius: 999px; + box-shadow: 0 2px 6px rgb(0 0 0 / 20%); +} + +.noteFooterInfo { + margin: 16px 0; + opacity: 0.7; + font-size: 0.9em; +} + +.noteFooterButton { + margin: 0; + padding: 8px; + opacity: 0.7; + + &:not(:last-child) { + margin-right: 28px; + } + + &:hover { + color: var(--fgHighlighted); + } +} + +.footerButtonLink:hover, +.footerButtonLink:focus, +.footerButtonLink:active { + text-decoration: none; +} + +.noteFooterButtonCount { + display: inline; + margin: 0 0 0 8px; + opacity: 0.7; + + &.reacted { + color: var(--accent); + } +} + +@container (max-width: 500px) { + .root { + font-size: 0.9em; + } +} + +@container (max-width: 450px) { + .renote { + padding: 8px 16px 0 16px; + } + + .note { + padding: 16px; + } + + .noteHeaderAvatar { + width: 50px; + height: 50px; + } +} + +@container (max-width: 350px) { + .noteFooterButton { + &:not(:last-child) { + margin-right: 18px; + } + } +} + +@container (max-width: 300px) { + .root { + font-size: 0.825em; + } + + .noteHeaderAvatar { + width: 50px; + height: 50px; + } + + .noteFooterButton { + &:not(:last-child) { + margin-right: 12px; + } + } +} +</style> diff --git a/packages/frontend-embed/src/components/EmNoteHeader.vue b/packages/frontend-embed/src/components/EmNoteHeader.vue new file mode 100644 index 0000000000000000000000000000000000000000..e4add9501f416182ca81ac42422ddc40666d77f6 --- /dev/null +++ b/packages/frontend-embed/src/components/EmNoteHeader.vue @@ -0,0 +1,104 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<header :class="$style.root"> + <EmA :class="$style.name" :to="userPage(note.user)"> + <EmUserName :user="note.user"/> + </EmA> + <div v-if="note.user.isBot" :class="$style.isBot">bot</div> + <div :class="$style.username"><EmAcct :user="note.user"/></div> + <div v-if="note.user.badgeRoles" :class="$style.badgeRoles"> + <img v-for="(role, i) in note.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl!"/> + </div> + <div :class="$style.info"> + <EmA :to="notePage(note)"> + <EmTime :time="note.createdAt" colored/> + </EmA> + <span v-if="note.visibility !== 'public'" style="margin-left: 0.5em;"> + <i v-if="note.visibility === 'home'" class="ti ti-home"></i> + <i v-else-if="note.visibility === 'followers'" class="ti ti-lock"></i> + <i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i> + </span> + <span v-if="note.localOnly" style="margin-left: 0.5em;"><i class="ti ti-rocket-off"></i></span> + <span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ti ti-device-tv"></i></span> + </div> +</header> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import * as Misskey from 'misskey-js'; +import { notePage } from '@/utils.js'; +import { userPage } from '@/utils.js'; +import EmA from '@/components/EmA.vue'; +import EmUserName from '@/components/EmUserName.vue'; +import EmAcct from '@/components/EmAcct.vue'; +import EmTime from '@/components/EmTime.vue'; + +defineProps<{ + note: Misskey.entities.Note; +}>(); +</script> + +<style lang="scss" module> +.root { + display: flex; + align-items: baseline; + white-space: nowrap; +} + +.name { + flex-shrink: 1; + display: block; + margin: 0 .5em 0 0; + padding: 0; + overflow: hidden; + font-size: 1em; + font-weight: bold; + text-decoration: none; + text-overflow: ellipsis; + + &:hover { + text-decoration: underline; + } +} + +.isBot { + flex-shrink: 0; + align-self: center; + margin: 0 .5em 0 0; + padding: 1px 6px; + font-size: 80%; + border: solid 0.5px var(--divider); + border-radius: 3px; +} + +.username { + flex-shrink: 9999999; + margin: 0 .5em 0 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.info { + flex-shrink: 0; + margin-left: auto; + font-size: 0.9em; +} + +.badgeRoles { + margin: 0 .5em 0 0; +} + +.badgeRole { + height: 1.3em; + vertical-align: -20%; + + & + .badgeRole { + margin-left: 0.2em; + } +} +</style> diff --git a/packages/frontend-embed/src/components/EmNoteSimple.vue b/packages/frontend-embed/src/components/EmNoteSimple.vue new file mode 100644 index 0000000000000000000000000000000000000000..828b6cd2e2ba05017c7b774c9f080bdedf088b07 --- /dev/null +++ b/packages/frontend-embed/src/components/EmNoteSimple.vue @@ -0,0 +1,105 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.root"> + <EmAvatar :class="$style.avatar" :user="note.user" link preview/> + <div :class="$style.main"> + <EmNoteHeader :class="$style.header" :note="note" :mini="true"/> + <div> + <p v-if="note.cw != null" :class="$style.cw"> + <EmMfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/> + <button style="display: block; width: 100%;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button> + </p> + <div v-show="note.cw == null || showContent"> + <EmSubNoteContent :class="$style.text" :note="note"/> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import { i18n } from '@/i18n.js'; +import EmNoteHeader from '@/components/EmNoteHeader.vue'; +import EmSubNoteContent from '@/components/EmSubNoteContent.vue'; +import EmMfm from '@/components/EmMfm.js'; + +const props = defineProps<{ + note: Misskey.entities.Note; +}>(); + +const showContent = ref(false); +</script> + +<style lang="scss" module> +.root { + display: flex; + margin: 0; + padding: 0; + font-size: 0.95em; +} + +.avatar { + flex-shrink: 0; + display: block; + margin: 0 10px 0 0; + width: 34px; + height: 34px; + border-radius: 8px; + position: sticky !important; + top: calc(16px + var(--stickyTop, 0px)); + left: 0; +} + +.main { + flex: 1; + min-width: 0; +} + +.header { + margin-bottom: 2px; +} + +.cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; +} + +.text { + cursor: default; + margin: 0; + padding: 0; +} + +@container (min-width: 250px) { + .avatar { + margin: 0 10px 0 0; + width: 40px; + height: 40px; + } +} + +@container (min-width: 350px) { + .avatar { + margin: 0 10px 0 0; + width: 44px; + height: 44px; + } +} + +@container (min-width: 500px) { + .avatar { + margin: 0 12px 0 0; + width: 48px; + height: 48px; + } +} +</style> diff --git a/packages/frontend-embed/src/components/EmNoteSub.vue b/packages/frontend-embed/src/components/EmNoteSub.vue new file mode 100644 index 0000000000000000000000000000000000000000..c98b956805bf4cfb6af82dd34d554396543ef0fc --- /dev/null +++ b/packages/frontend-embed/src/components/EmNoteSub.vue @@ -0,0 +1,149 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="[$style.root, { [$style.children]: depth > 1 }]"> + <div :class="$style.main"> + <div v-if="note.channel" :class="$style.colorBar" :style="{ background: note.channel.color }"></div> + <EmAvatar :class="$style.avatar" :user="note.user" link preview/> + <div :class="$style.body"> + <EmNoteHeader :class="$style.header" :note="note" :mini="true"/> + <div> + <p v-if="note.cw != null" :class="$style.cw"> + <EmMfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'"/> + <button style="display: block; width: 100%;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button> + </p> + <div v-show="note.cw == null || showContent"> + <EmSubNoteContent :class="$style.text" :note="note"/> + </div> + </div> + </div> + </div> + <template v-if="depth < 5"> + <EmNoteSub v-for="reply in replies" :key="reply.id" :note="reply" :class="$style.reply" :detail="true" :depth="depth + 1"/> + </template> + <div v-else :class="$style.more"> + <EmA class="_link" :to="notePage(note)">{{ i18n.ts.continueThread }} <i class="ti ti-chevron-double-right"></i></EmA> + </div> +</div> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import EmNoteHeader from '@/components/EmNoteHeader.vue'; +import EmSubNoteContent from '@/components/EmSubNoteContent.vue'; +import { notePage } from '@/utils.js'; +import { misskeyApi } from '@/misskey-api.js'; +import { i18n } from '@/i18n.js'; +import EmMfm from '@/components/EmMfm.js'; + +const props = withDefaults(defineProps<{ + note: Misskey.entities.Note; + detail?: boolean; + + // how many notes are in between this one and the note being viewed in detail + depth?: number; +}>(), { + depth: 1, +}); + +const showContent = ref(false); +const replies = ref<Misskey.entities.Note[]>([]); + +if (props.detail) { + misskeyApi('notes/children', { + noteId: props.note.id, + limit: 5, + }).then(res => { + replies.value = res; + }); +} +</script> + +<style lang="scss" module> +.root { + padding: 16px 32px; + font-size: 0.9em; + position: relative; + + &.children { + padding: 10px 0 0 16px; + font-size: 1em; + } +} + +.main { + display: flex; +} + +.colorBar { + position: absolute; + top: 8px; + left: 8px; + width: 5px; + height: calc(100% - 8px); + border-radius: 999px; + pointer-events: none; +} + +.avatar { + flex-shrink: 0; + display: block; + margin: 0 8px 0 0; + width: 38px; + height: 38px; + border-radius: 8px; +} + +.body { + flex: 1; + min-width: 0; +} + +.header { + margin-bottom: 2px; +} + +.cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; +} + +.text { + margin: 0; + padding: 0; +} + +.reply, .more { + border-left: solid 0.5px var(--divider); + margin-top: 10px; +} + +.more { + padding: 10px 0 0 16px; +} + +@container (max-width: 450px) { + .root { + padding: 14px 16px; + + &.children { + padding: 10px 0 0 8px; + } + } +} + +.muted { + text-align: center; + padding: 8px !important; + border: 1px solid var(--divider); + margin: 8px 8px 0 8px; + border-radius: 8px; +} +</style> diff --git a/packages/frontend-embed/src/components/EmNotes.vue b/packages/frontend-embed/src/components/EmNotes.vue new file mode 100644 index 0000000000000000000000000000000000000000..3970d050988f2bc3c464579fd8c1c7cf2ab8fedb --- /dev/null +++ b/packages/frontend-embed/src/components/EmNotes.vue @@ -0,0 +1,48 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<EmPagination ref="pagingComponent" :pagination="pagination" :disableAutoLoad="disableAutoLoad"> + <template #empty> + <div class="_fullinfo"> + <div>{{ i18n.ts.noNotes }}</div> + </div> + </template> + + <template #default="{ items: notes }"> + <div :class="[$style.root]"> + <EmNote v-for="note in notes" :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note"/> + </div> + </template> +</EmPagination> +</template> + +<script lang="ts" setup> +import { shallowRef } from 'vue'; +import EmNote from '@/components/EmNote.vue'; +import EmPagination, { Paging } from '@/components/EmPagination.vue'; +import { i18n } from '@/i18n.js'; + +const props = withDefaults(defineProps<{ + pagination: Paging; + noGap?: boolean; + disableAutoLoad?: boolean; + ad?: boolean; +}>(), { + ad: true, +}); + +const pagingComponent = shallowRef<InstanceType<typeof EmPagination>>(); + +defineExpose({ + pagingComponent, +}); +</script> + +<style lang="scss" module> +.root { + background: var(--panel); +} +</style> diff --git a/packages/frontend-embed/src/components/EmPagination.vue b/packages/frontend-embed/src/components/EmPagination.vue new file mode 100644 index 0000000000000000000000000000000000000000..5d5317a91281ac62afbf56f119ca86b5d27cb6bd --- /dev/null +++ b/packages/frontend-embed/src/components/EmPagination.vue @@ -0,0 +1,504 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<EmLoading v-if="fetching"/> + +<EmError v-else-if="error" @retry="init()"/> + +<div v-else-if="empty" key="_empty_" class="empty"> + <slot name="empty"> + <div class="_fullinfo"> + <div>{{ i18n.ts.nothing }}</div> + </div> + </slot> +</div> + +<div v-else ref="rootEl"> + <div v-show="pagination.reversed && more" key="_more_" class="_margin"> + <button v-if="!moreFetching" class="_buttonPrimary" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMoreAhead"> + {{ i18n.ts.loadMore }} + </button> + <EmLoading v-else class="loading"/> + </div> + <slot :items="Array.from(items.values())" :fetching="fetching || moreFetching"></slot> + <div v-show="!pagination.reversed && more" key="_more_" class="_margin"> + <button v-if="!moreFetching" class="_buttonRounded _buttonPrimary" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMore"> + {{ i18n.ts.loadMore }} + </button> + <EmLoading v-else class="loading"/> + </div> +</div> +</template> + +<script lang="ts"> +import { computed, ComputedRef, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, shallowRef, watch } from 'vue'; +import * as Misskey from 'misskey-js'; +import { useDocumentVisibility } from '@@/js/use-document-visibility.js'; +import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@@/js/scroll.js'; +import { misskeyApi } from '@/misskey-api.js'; +import { i18n } from '@/i18n.js'; + +const SECOND_FETCH_LIMIT = 30; +const TOLERANCE = 16; +const APPEAR_MINIMUM_INTERVAL = 600; + +export type Paging<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints> = { + endpoint: E; + limit: number; + params?: Misskey.Endpoints[E]['req'] | ComputedRef<Misskey.Endpoints[E]['req']>; + + /** + * 検索APIã®ã‚ˆã†ãªã€ãƒšãƒ¼ã‚¸ãƒ³ã‚°ä¸å¯ãªã‚¨ãƒ³ãƒ‰ãƒã‚¤ãƒ³ãƒˆã‚’利用ã™ã‚‹å ´åˆ + * (ãã®ã‚ˆã†ãªAPIã‚’ã“ã®é–¢æ•°ã§ä½¿ã†ã®ã¯è‹¥å¹²çŸ›ç›¾ã—ã¦ã‚‹ã‘ã©) + */ + noPaging?: boolean; + + /** + * items é…列ã®ä¸èº«ã‚’é€†é †ã«ã™ã‚‹(æ–°ã—ã„æ–¹ãŒæœ€å¾Œ) + */ + reversed?: boolean; + + offsetMode?: boolean; + + pageEl?: HTMLElement; +}; + +type MisskeyEntity = { + id: string; + createdAt: string; + _shouldInsertAd_?: boolean; + [x: string]: any; +}; + +type MisskeyEntityMap = Map<string, MisskeyEntity>; + +function arrayToEntries(entities: MisskeyEntity[]): [string, MisskeyEntity][] { + return entities.map(en => [en.id, en]); +} + +function concatMapWithArray(map: MisskeyEntityMap, entities: MisskeyEntity[]): MisskeyEntityMap { + return new Map([...map, ...arrayToEntries(entities)]); +} + +</script> +<script lang="ts" setup> +import EmError from '@/components/EmError.vue'; +import EmLoading from '@/components/EmLoading.vue'; + +const props = withDefaults(defineProps<{ + pagination: Paging; + disableAutoLoad?: boolean; + displayLimit?: number; +}>(), { + displayLimit: 20, +}); + +const emit = defineEmits<{ + (ev: 'queue', count: number): void; + (ev: 'status', error: boolean): void; +}>(); + +const rootEl = shallowRef<HTMLElement>(); + +// é¡ã‚Šä¸ã‹ã©ã†ã‹ +const backed = ref(false); + +const scrollRemove = ref<(() => void) | null>(null); + +/** + * 表示ã™ã‚‹ã‚¢ã‚¤ãƒ†ãƒ ã®ã‚½ãƒ¼ã‚¹ + * 最新ãŒ0番目 + */ +const items = ref<MisskeyEntityMap>(new Map()); + +/** + * タブãŒéžã‚¢ã‚¯ãƒ†ã‚£ãƒ–ãªã©ã®å ´åˆã«æ›´æ–°ã‚’貯ã‚ã¦ãŠã + * 最新ãŒ0番目 + */ +const queue = ref<MisskeyEntityMap>(new Map()); + +const offset = ref(0); + +/** + * åˆæœŸåŒ–ä¸ã‹ã©ã†ã‹ï¼ˆtrueãªã‚‰EmLoadingã§å…¨ã¦éš ã™ï¼‰ + */ +const fetching = ref(true); + +const moreFetching = ref(false); +const more = ref(false); +const preventAppearFetchMore = ref(false); +const preventAppearFetchMoreTimer = ref<number | null>(null); +const isBackTop = ref(false); +const empty = computed(() => items.value.size === 0); +const error = ref(false); + +const contentEl = computed(() => props.pagination.pageEl ?? rootEl.value); +const scrollableElement = computed(() => contentEl.value ? getScrollContainer(contentEl.value) : document.body); + +const visibility = useDocumentVisibility(); + +let isPausingUpdate = false; +let timerForSetPause: number | null = null; +const BACKGROUND_PAUSE_WAIT_SEC = 10; + +// å…ˆé ãŒè¡¨ç¤ºã•ã‚Œã¦ã„ã‚‹ã‹ã©ã†ã‹ã‚’検出 +// https://qiita.com/mkataigi/items/0154aefd2223ce23398e +const scrollObserver = ref<IntersectionObserver>(); + +watch([() => props.pagination.reversed, scrollableElement], () => { + if (scrollObserver.value) scrollObserver.value.disconnect(); + + scrollObserver.value = new IntersectionObserver(entries => { + backed.value = entries[0].isIntersecting; + }, { + root: scrollableElement.value, + rootMargin: props.pagination.reversed ? '-100% 0px 100% 0px' : '100% 0px -100% 0px', + threshold: 0.01, + }); +}, { immediate: true }); + +watch(rootEl, () => { + scrollObserver.value?.disconnect(); + nextTick(() => { + if (rootEl.value) scrollObserver.value?.observe(rootEl.value); + }); +}); + +watch([backed, contentEl], () => { + if (!backed.value) { + if (!contentEl.value) return; + + scrollRemove.value = (props.pagination.reversed ? onScrollBottom : onScrollTop)(contentEl.value, executeQueue, TOLERANCE); + } else { + if (scrollRemove.value) scrollRemove.value(); + scrollRemove.value = null; + } +}); + +// パラメータã«ä½•ã‚‰ã‹ã®å¤‰æ›´ãŒã‚ã£ãŸéš›ã€å†èªè¾¼ã—ãŸã„(ãƒãƒ£ãƒ³ãƒãƒ«ç‰ã®IDãŒå¤‰ã‚ã£ãŸãªã©ï¼‰ +watch(() => [props.pagination.endpoint, props.pagination.params], init, { deep: true }); + +watch(queue, (a, b) => { + if (a.size === 0 && b.size === 0) return; + emit('queue', queue.value.size); +}, { deep: true }); + +watch(error, (n, o) => { + if (n === o) return; + emit('status', n); +}); + +async function init(): Promise<void> { + items.value = new Map(); + queue.value = new Map(); + fetching.value = true; + const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; + await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, { + ...params, + limit: props.pagination.limit ?? 10, + allowPartial: true, + }).then(res => { + for (let i = 0; i < res.length; i++) { + const item = res[i]; + if (i === 3) item._shouldInsertAd_ = true; + } + + if (res.length === 0 || props.pagination.noPaging) { + concatItems(res); + more.value = false; + } else { + if (props.pagination.reversed) moreFetching.value = true; + concatItems(res); + more.value = true; + } + + offset.value = res.length; + error.value = false; + fetching.value = false; + }, err => { + error.value = true; + fetching.value = false; + }); +} + +const reload = (): Promise<void> => { + return init(); +}; + +const fetchMore = async (): Promise<void> => { + if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return; + moreFetching.value = true; + const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; + await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, { + ...params, + limit: SECOND_FETCH_LIMIT, + ...(props.pagination.offsetMode ? { + offset: offset.value, + } : { + untilId: Array.from(items.value.keys()).at(-1), + }), + }).then(res => { + for (let i = 0; i < res.length; i++) { + const item = res[i]; + if (i === 10) item._shouldInsertAd_ = true; + } + + const reverseConcat = _res => { + const oldHeight = scrollableElement.value ? scrollableElement.value.scrollHeight : getBodyScrollHeight(); + const oldScroll = scrollableElement.value ? scrollableElement.value.scrollTop : window.scrollY; + + items.value = concatMapWithArray(items.value, _res); + + return nextTick(() => { + if (scrollableElement.value) { + scroll(scrollableElement.value, { top: oldScroll + (scrollableElement.value.scrollHeight - oldHeight), behavior: 'instant' }); + } else { + window.scroll({ top: oldScroll + (getBodyScrollHeight() - oldHeight), behavior: 'instant' }); + } + + return nextTick(); + }); + }; + + if (res.length === 0) { + if (props.pagination.reversed) { + reverseConcat(res).then(() => { + more.value = false; + moreFetching.value = false; + }); + } else { + items.value = concatMapWithArray(items.value, res); + more.value = false; + moreFetching.value = false; + } + } else { + if (props.pagination.reversed) { + reverseConcat(res).then(() => { + more.value = true; + moreFetching.value = false; + }); + } else { + items.value = concatMapWithArray(items.value, res); + more.value = true; + moreFetching.value = false; + } + } + offset.value += res.length; + }, err => { + moreFetching.value = false; + }); +}; + +const fetchMoreAhead = async (): Promise<void> => { + if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return; + moreFetching.value = true; + const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; + await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, { + ...params, + limit: SECOND_FETCH_LIMIT, + ...(props.pagination.offsetMode ? { + offset: offset.value, + } : { + sinceId: Array.from(items.value.keys()).at(-1), + }), + }).then(res => { + if (res.length === 0) { + items.value = concatMapWithArray(items.value, res); + more.value = false; + } else { + items.value = concatMapWithArray(items.value, res); + more.value = true; + } + offset.value += res.length; + moreFetching.value = false; + }, err => { + moreFetching.value = false; + }); +}; + +/** + * Appear(IntersectionObserver)ã«ã‚ˆã£ã¦fetchMoreãŒå‘¼ã°ã‚Œã‚‹å ´åˆã€ + * APPEAR_MINIMUM_INTERVALミリ秒以内ã«2回fetchMoreãŒå‘¼ã°ã‚Œã‚‹ã®ã‚’防ã + */ +const fetchMoreApperTimeoutFn = (): void => { + preventAppearFetchMore.value = false; + preventAppearFetchMoreTimer.value = null; +}; +const fetchMoreAppearTimeout = (): void => { + preventAppearFetchMore.value = true; + preventAppearFetchMoreTimer.value = window.setTimeout(fetchMoreApperTimeoutFn, APPEAR_MINIMUM_INTERVAL); +}; + +const appearFetchMore = async (): Promise<void> => { + if (preventAppearFetchMore.value) return; + await fetchMore(); + fetchMoreAppearTimeout(); +}; + +const appearFetchMoreAhead = async (): Promise<void> => { + if (preventAppearFetchMore.value) return; + await fetchMoreAhead(); + fetchMoreAppearTimeout(); +}; + +const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl.value!, TOLERANCE); + +watch(visibility, () => { + if (visibility.value === 'hidden') { + timerForSetPause = window.setTimeout(() => { + isPausingUpdate = true; + timerForSetPause = null; + }, + BACKGROUND_PAUSE_WAIT_SEC * 1000); + } else { // 'visible' + if (timerForSetPause) { + clearTimeout(timerForSetPause); + timerForSetPause = null; + } else { + isPausingUpdate = false; + if (isTop()) { + executeQueue(); + } + } + } +}); + +/** + * 最新ã®ã‚‚ã®ã¨ã—ã¦1ã¤ã ã‘ã‚¢ã‚¤ãƒ†ãƒ ã‚’è¿½åŠ ã™ã‚‹ + * ストリーミングã‹ã‚‰é™ã£ã¦ããŸã‚¢ã‚¤ãƒ†ãƒ ã¯ã“ã‚Œã§è¿½åŠ ã™ã‚‹ + * @param item アイテム+ */ +const prepend = (item: MisskeyEntity): void => { + if (items.value.size === 0) { + items.value.set(item.id, item); + fetching.value = false; + return; + } + + if (isTop() && !isPausingUpdate) unshiftItems([item]); + else prependQueue(item); +}; + +/** + * æ–°ç€ã‚¢ã‚¤ãƒ†ãƒ ã‚’itemsã®å…ˆé ã«è¿½åŠ ã—ã€displayLimitã‚’é©ç”¨ã™ã‚‹ + * @param newItems æ–°ã—ã„アイテムã®é…列 + */ +function unshiftItems(newItems: MisskeyEntity[]) { + const length = newItems.length + items.value.size; + items.value = new Map([...arrayToEntries(newItems), ...items.value].slice(0, props.displayLimit)); + + if (length >= props.displayLimit) more.value = true; +} + +/** + * å¤ã„アイテムをitemsã®æœ«å°¾ã«è¿½åŠ ã—ã€displayLimitã‚’é©ç”¨ã™ã‚‹ + * @param oldItems å¤ã„アイテムã®é…列 + */ +function concatItems(oldItems: MisskeyEntity[]) { + const length = oldItems.length + items.value.size; + items.value = new Map([...items.value, ...arrayToEntries(oldItems)].slice(0, props.displayLimit)); + + if (length >= props.displayLimit) more.value = true; +} + +function executeQueue() { + unshiftItems(Array.from(queue.value.values())); + queue.value = new Map(); +} + +function prependQueue(newItem: MisskeyEntity) { + queue.value = new Map([[newItem.id, newItem], ...queue.value].slice(0, props.displayLimit) as [string, MisskeyEntity][]); +} + +/* + * アイテムを末尾ã«è¿½åŠ ã™ã‚‹ï¼ˆä½¿ã†ã®ï¼Ÿï¼‰ + */ +const appendItem = (item: MisskeyEntity): void => { + items.value.set(item.id, item); +}; + +const removeItem = (id: string) => { + items.value.delete(id); + queue.value.delete(id); +}; + +const updateItem = (id: MisskeyEntity['id'], replacer: (old: MisskeyEntity) => MisskeyEntity): void => { + const item = items.value.get(id); + if (item) items.value.set(id, replacer(item)); + + const queueItem = queue.value.get(id); + if (queueItem) queue.value.set(id, replacer(queueItem)); +}; + +onActivated(() => { + isBackTop.value = false; +}); + +onDeactivated(() => { + isBackTop.value = props.pagination.reversed ? window.scrollY >= (rootEl.value ? rootEl.value.scrollHeight - window.innerHeight : 0) : window.scrollY === 0; +}); + +function toBottom() { + scrollToBottom(contentEl.value!); +} + +onBeforeMount(() => { + init().then(() => { + if (props.pagination.reversed) { + nextTick(() => { + setTimeout(toBottom, 800); + + // scrollToBottomã§moreFetchingボタンãŒç”»é¢å¤–ã¾ã§å‡ºã‚‹ã¾ã§ + // more = trueã‚’é…らã›ã‚‹ + setTimeout(() => { + moreFetching.value = false; + }, 2000); + }); + } + }); +}); + +onBeforeUnmount(() => { + if (timerForSetPause) { + clearTimeout(timerForSetPause); + timerForSetPause = null; + } + if (preventAppearFetchMoreTimer.value) { + clearTimeout(preventAppearFetchMoreTimer.value); + preventAppearFetchMoreTimer.value = null; + } + scrollObserver.value?.disconnect(); +}); + +defineExpose({ + items, + queue, + backed: backed.value, + more, + reload, + prepend, + append: appendItem, + removeItem, + updateItem, +}); +</script> + +<style lang="scss" module> +.transition_fade_enterActive, +.transition_fade_leaveActive { + transition: opacity 0.125s ease; +} +.transition_fade_enterFrom, +.transition_fade_leaveTo { + opacity: 0; +} + +.more { + display: block; + margin-left: auto; + margin-right: auto; +} +</style> diff --git a/packages/frontend-embed/src/components/EmPoll.vue b/packages/frontend-embed/src/components/EmPoll.vue new file mode 100644 index 0000000000000000000000000000000000000000..a2b1203449403116027b4cd0b17725392269d68f --- /dev/null +++ b/packages/frontend-embed/src/components/EmPoll.vue @@ -0,0 +1,82 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div> + <ul :class="$style.choices"> + <li v-for="(choice, i) in poll.choices" :key="i" :class="$style.choice"> + <div :class="$style.bg" :style="{ 'width': `${choice.votes / total * 100}%` }"></div> + <span :class="$style.fg"> + <template v-if="choice.isVoted"><i class="ti ti-check" style="margin-right: 4px; color: var(--accent);"></i></template> + <EmMfm :text="choice.text" :plain="true"/> + <span style="margin-left: 4px; opacity: 0.7;">({{ i18n.tsx._poll.votesCount({ n: choice.votes }) }})</span> + </span> + </li> + </ul> + <p :class="$style.info"> + <span>{{ i18n.tsx._poll.totalVotes({ n: total }) }}</span> + </p> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import * as Misskey from 'misskey-js'; +import { i18n } from '@/i18n.js'; +import EmMfm from '@/components/EmMfm.js'; + +function sum(xs: number[]): number { + return xs.reduce((a, b) => a + b, 0); +} + +const props = defineProps<{ + noteId: string; + poll: NonNullable<Misskey.entities.Note['poll']>; +}>(); + +const total = computed(() => sum(props.poll.choices.map(x => x.votes))); +</script> + +<style lang="scss" module> +.choices { + display: block; + margin: 0; + padding: 0; + list-style: none; +} + +.choice { + display: block; + position: relative; + margin: 4px 0; + padding: 4px; + //border: solid 0.5px var(--divider); + background: var(--accentedBg); + border-radius: 4px; + overflow: clip; +} + +.bg { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: var(--accent); + background: linear-gradient(90deg,var(--buttonGradateA),var(--buttonGradateB)); + transition: width 1s ease; +} + +.fg { + position: relative; + display: inline-block; + padding: 3px 5px; + background: var(--panel); + border-radius: 3px; +} + +.info { + color: var(--fg); +} +</style> diff --git a/packages/frontend-embed/src/components/EmReactionIcon.vue b/packages/frontend-embed/src/components/EmReactionIcon.vue new file mode 100644 index 0000000000000000000000000000000000000000..5c38ecb0ed3b4a192afb15da3e1ebe8a72c3a40a --- /dev/null +++ b/packages/frontend-embed/src/components/EmReactionIcon.vue @@ -0,0 +1,23 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<EmCustomEmoji v-if="reaction[0] === ':'" ref="elRef" :name="reaction" :normal="true" :noStyle="noStyle" :url="emojiUrl" :fallbackToImage="true"/> +<EmEmoji v-else ref="elRef" :emoji="reaction" :normal="true" :noStyle="noStyle"/> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import EmCustomEmoji from './EmCustomEmoji.vue'; +import EmEmoji from './EmEmoji.vue'; + +const props = defineProps<{ + reaction: string; + noStyle?: boolean; + emojiUrl?: string; + withTooltip?: boolean; +}>(); + +</script> diff --git a/packages/frontend-embed/src/components/EmReactionsViewer.reaction.vue b/packages/frontend-embed/src/components/EmReactionsViewer.reaction.vue new file mode 100644 index 0000000000000000000000000000000000000000..2e43eb8d170be6df285048ec15483e2b3c0d3b4a --- /dev/null +++ b/packages/frontend-embed/src/components/EmReactionsViewer.reaction.vue @@ -0,0 +1,99 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<button + class="_button" + :class="[$style.root, { [$style.reacted]: note.myReaction == reaction }]" +> + <EmReactionIcon :class="$style.limitWidth" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substring(1, reaction.length - 1)]"/> + <span :class="$style.count">{{ count }}</span> +</button> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import * as Misskey from 'misskey-js'; +import EmReactionIcon from '@/components/EmReactionIcon.vue'; + +const props = defineProps<{ + reaction: string; + count: number; + isInitial: boolean; + note: Misskey.entities.Note; +}>(); +</script> + +<style lang="scss" module> +.root { + display: inline-flex; + height: 42px; + margin: 2px; + padding: 0 6px; + font-size: 1.5em; + border-radius: 6px; + align-items: center; + justify-content: center; + + &.canToggle { + background: var(--buttonBg); + + &:hover { + background: rgba(0, 0, 0, 0.1); + } + } + + &:not(.canToggle) { + cursor: default; + } + + &.small { + height: 32px; + font-size: 1em; + border-radius: 4px; + + > .count { + font-size: 0.9em; + line-height: 32px; + } + } + + &.large { + height: 52px; + font-size: 2em; + border-radius: 8px; + + > .count { + font-size: 0.6em; + line-height: 52px; + } + } + + &.reacted, &.reacted:hover { + background: var(--accentedBg); + color: var(--accent); + box-shadow: 0 0 0 1px var(--accent) inset; + + > .count { + color: var(--accent); + } + + > .icon { + filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.5)); + } + } +} + +.limitWidth { + max-width: 70px; + object-fit: contain; +} + +.count { + font-size: 0.7em; + line-height: 42px; + margin: 0 0 0 4px; +} +</style> diff --git a/packages/frontend-embed/src/components/EmReactionsViewer.vue b/packages/frontend-embed/src/components/EmReactionsViewer.vue new file mode 100644 index 0000000000000000000000000000000000000000..014dd1c935f8bc594aca398806dd751b2949204b --- /dev/null +++ b/packages/frontend-embed/src/components/EmReactionsViewer.vue @@ -0,0 +1,104 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.root"> + <XReaction v-for="[reaction, count] in reactions" :key="reaction" :reaction="reaction" :count="count" :isInitial="initialReactions.has(reaction)" :note="note" @reactionToggled="onMockToggleReaction"/> + <slot v-if="hasMoreReactions" name="more"></slot> +</div> +</template> + +<script lang="ts" setup> +import * as Misskey from 'misskey-js'; +import { inject, watch, ref } from 'vue'; +import XReaction from '@/components/EmReactionsViewer.reaction.vue'; + +const props = withDefaults(defineProps<{ + note: Misskey.entities.Note; + maxNumber?: number; +}>(), { + maxNumber: Infinity, +}); + +const mock = inject<boolean>('mock', false); + +const emit = defineEmits<{ + (ev: 'mockUpdateMyReaction', emoji: string, delta: number): void; +}>(); + +const initialReactions = new Set(Object.keys(props.note.reactions)); + +const reactions = ref<[string, number][]>([]); +const hasMoreReactions = ref(false); + +if (props.note.myReaction && !Object.keys(reactions.value).includes(props.note.myReaction)) { + reactions.value[props.note.myReaction] = props.note.reactions[props.note.myReaction]; +} + +function onMockToggleReaction(emoji: string, count: number) { + if (!mock) return; + + const i = reactions.value.findIndex((item) => item[0] === emoji); + if (i < 0) return; + + emit('mockUpdateMyReaction', emoji, (count - reactions.value[i][1])); +} + +watch([() => props.note.reactions, () => props.maxNumber], ([newSource, maxNumber]) => { + let newReactions: [string, number][] = []; + hasMoreReactions.value = Object.keys(newSource).length > maxNumber; + + for (let i = 0; i < reactions.value.length; i++) { + const reaction = reactions.value[i][0]; + if (reaction in newSource && newSource[reaction] !== 0) { + reactions.value[i][1] = newSource[reaction]; + newReactions.push(reactions.value[i]); + } + } + + const newReactionsNames = newReactions.map(([x]) => x); + newReactions = [ + ...newReactions, + ...Object.entries(newSource) + .sort(([, a], [, b]) => b - a) + .filter(([y], i) => i < maxNumber && !newReactionsNames.includes(y)), + ]; + + newReactions = newReactions.slice(0, props.maxNumber); + + if (props.note.myReaction && !newReactions.map(([x]) => x).includes(props.note.myReaction)) { + newReactions.push([props.note.myReaction, newSource[props.note.myReaction]]); + } + + reactions.value = newReactions; +}, { immediate: true, deep: true }); +</script> + +<style lang="scss" module> +.transition_x_move, +.transition_x_enterActive, +.transition_x_leaveActive { + transition: opacity 0.2s cubic-bezier(0,.5,.5,1), transform 0.2s cubic-bezier(0,.5,.5,1) !important; +} +.transition_x_enterFrom, +.transition_x_leaveTo { + opacity: 0; + transform: scale(0.7); +} +.transition_x_leaveActive { + position: absolute; +} + +.root { + display: flex; + flex-wrap: wrap; + align-items: center; + margin: 4px -2px 0 -2px; + + &:empty { + display: none; + } +} +</style> diff --git a/packages/frontend-embed/src/components/EmSubNoteContent.vue b/packages/frontend-embed/src/components/EmSubNoteContent.vue new file mode 100644 index 0000000000000000000000000000000000000000..382e39e4928bfbe72e8d2d53b7a756ea5b7cb83a --- /dev/null +++ b/packages/frontend-embed/src/components/EmSubNoteContent.vue @@ -0,0 +1,113 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="[$style.root, { [$style.collapsed]: collapsed }]"> + <div> + <span v-if="note.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> + <span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deletedNote }})</span> + <EmA v-if="note.replyId" :class="$style.reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></EmA> + <EmMfm v-if="note.text" :text="note.text" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/> + <EmA v-if="note.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`">RN: ...</EmA> + </div> + <details v-if="note.files && note.files.length > 0"> + <summary>({{ i18n.tsx.withNFiles({ n: note.files.length }) }})</summary> + <EmMediaList :mediaList="note.files" :originalEntityUrl="`${url}/notes/${note.id}`"/> + </details> + <details v-if="note.poll"> + <summary>{{ i18n.ts.poll }}</summary> + <EmPoll :noteId="note.id" :poll="note.poll"/> + </details> + <button v-if="isLong && collapsed" :class="$style.fade" class="_button" @click="collapsed = false"> + <span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span> + </button> + <button v-else-if="isLong && !collapsed" :class="$style.showLess" class="_button" @click="collapsed = true"> + <span :class="$style.showLessLabel">{{ i18n.ts.showLess }}</span> + </button> +</div> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import EmMediaList from '@/components/EmMediaList.vue'; +import EmPoll from '@/components/EmPoll.vue'; +import { i18n } from '@/i18n.js'; +import { url } from '@/config.js'; +import { shouldCollapsed } from '@/to-be-shared/collapsed.js'; +import EmMfm from '@/components/EmMfm.js'; + +const props = defineProps<{ + note: Misskey.entities.Note; +}>(); + +const isLong = shouldCollapsed(props.note, []); + +const collapsed = ref(isLong); +</script> + +<style lang="scss" module> +.root { + overflow-wrap: break-word; + + &.collapsed { + position: relative; + max-height: 9em; + overflow: clip; + + > .fade { + display: block; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 64px; + background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0)); + + > .fadeLabel { + display: inline-block; + background: var(--panel); + padding: 6px 10px; + font-size: 0.8em; + border-radius: 999px; + box-shadow: 0 2px 6px rgb(0 0 0 / 20%); + } + + &:hover { + > .fadeLabel { + background: var(--panelHighlight); + } + } + } + } +} + +.reply { + margin-right: 6px; + color: var(--accent); +} + +.rp { + margin-left: 4px; + font-style: oblique; + color: var(--renote); +} + +.showLess { + width: 100%; + margin-top: 14px; + position: sticky; + bottom: calc(var(--stickyBottom, 0px) + 14px); +} + +.showLessLabel { + display: inline-block; + background: var(--popup); + padding: 6px 10px; + font-size: 0.8em; + border-radius: 999px; + box-shadow: 0 2px 6px rgb(0 0 0 / 20%); +} +</style> diff --git a/packages/frontend-embed/src/components/EmTime.vue b/packages/frontend-embed/src/components/EmTime.vue new file mode 100644 index 0000000000000000000000000000000000000000..a8627e02c847091fdf41e19bffcd3a8ad22401b4 --- /dev/null +++ b/packages/frontend-embed/src/components/EmTime.vue @@ -0,0 +1,107 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<time :title="absolute" :class="{ [$style.old1]: colored && (ago > 60 * 60 * 24 * 90), [$style.old2]: colored && (ago > 60 * 60 * 24 * 180) }"> + <template v-if="invalid">{{ i18n.ts._ago.invalid }}</template> + <template v-else-if="mode === 'relative'">{{ relative }}</template> + <template v-else-if="mode === 'absolute'">{{ absolute }}</template> + <template v-else-if="mode === 'detail'">{{ absolute }} ({{ relative }})</template> +</time> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref, computed } from 'vue'; +import { i18n } from '@/i18n.js'; +import { dateTimeFormat } from '@/to-be-shared/intl-const.js'; + +const props = withDefaults(defineProps<{ + time: Date | string | number | null; + origin?: Date | null; + mode?: 'relative' | 'absolute' | 'detail'; + colored?: boolean; +}>(), { + origin: null, + mode: 'relative', +}); + +function getDateSafe(n: Date | string | number) { + try { + if (n instanceof Date) { + return n; + } + return new Date(n); + } catch (err) { + return { + getTime: () => NaN, + }; + } +} + +// eslint-disable-next-line vue/no-setup-props-reactivity-loss +const _time = props.time == null ? NaN : getDateSafe(props.time).getTime(); +const invalid = Number.isNaN(_time); +const absolute = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid; + +// eslint-disable-next-line vue/no-setup-props-reactivity-loss +const now = ref(props.origin?.getTime() ?? Date.now()); +const ago = computed(() => (now.value - _time) / 1000/*ms*/); + +const relative = computed<string>(() => { + if (props.mode === 'absolute') return ''; // absoluteã§ã¯relativeを使ã‚ãªã„ã®ã§è¨ˆç®—ã—ãªã„ + if (invalid) return i18n.ts._ago.invalid; + + return ( + ago.value >= 31536000 ? i18n.tsx._ago.yearsAgo({ n: Math.round(ago.value / 31536000).toString() }) : + ago.value >= 2592000 ? i18n.tsx._ago.monthsAgo({ n: Math.round(ago.value / 2592000).toString() }) : + ago.value >= 604800 ? i18n.tsx._ago.weeksAgo({ n: Math.round(ago.value / 604800).toString() }) : + ago.value >= 86400 ? i18n.tsx._ago.daysAgo({ n: Math.round(ago.value / 86400).toString() }) : + ago.value >= 3600 ? i18n.tsx._ago.hoursAgo({ n: Math.round(ago.value / 3600).toString() }) : + ago.value >= 60 ? i18n.tsx._ago.minutesAgo({ n: (~~(ago.value / 60)).toString() }) : + ago.value >= 10 ? i18n.tsx._ago.secondsAgo({ n: (~~(ago.value % 60)).toString() }) : + ago.value >= -3 ? i18n.ts._ago.justNow : + ago.value < -31536000 ? i18n.tsx._timeIn.years({ n: Math.round(-ago.value / 31536000).toString() }) : + ago.value < -2592000 ? i18n.tsx._timeIn.months({ n: Math.round(-ago.value / 2592000).toString() }) : + ago.value < -604800 ? i18n.tsx._timeIn.weeks({ n: Math.round(-ago.value / 604800).toString() }) : + ago.value < -86400 ? i18n.tsx._timeIn.days({ n: Math.round(-ago.value / 86400).toString() }) : + ago.value < -3600 ? i18n.tsx._timeIn.hours({ n: Math.round(-ago.value / 3600).toString() }) : + ago.value < -60 ? i18n.tsx._timeIn.minutes({ n: (~~(-ago.value / 60)).toString() }) : + i18n.tsx._timeIn.seconds({ n: (~~(-ago.value % 60)).toString() }) + ); +}); + +let tickId: number; +let currentInterval: number; + +function tick() { + now.value = Date.now(); + const nextInterval = ago.value < 60 ? 10000 : ago.value < 3600 ? 60000 : 180000; + + if (currentInterval !== nextInterval) { + if (tickId) window.clearInterval(tickId); + currentInterval = nextInterval; + tickId = window.setInterval(tick, nextInterval); + } +} + +if (!invalid && props.origin === null && (props.mode === 'relative' || props.mode === 'detail')) { + onMounted(() => { + tick(); + }); + onUnmounted(() => { + if (tickId) window.clearInterval(tickId); + }); +} +</script> + +<style lang="scss" module> +.old1 { + color: var(--warn); +} + +.old1.old2 { + color: var(--error); +} +</style> diff --git a/packages/frontend-embed/src/components/EmTimelineContainer.vue b/packages/frontend-embed/src/components/EmTimelineContainer.vue new file mode 100644 index 0000000000000000000000000000000000000000..6c30b1102d9ed2decdb7bbd84390be9b8fe263f7 --- /dev/null +++ b/packages/frontend-embed/src/components/EmTimelineContainer.vue @@ -0,0 +1,39 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.timelineRoot"> + <div v-if="showHeader" :class="$style.header"><slot name="header"></slot></div> + <div :class="$style.body"><slot name="body"></slot></div> +</div> +</template> + +<script setup lang="ts"> +withDefaults(defineProps<{ + showHeader?: boolean; +}>(), { + showHeader: true, +}); +</script> + +<style module lang="scss"> +.timelineRoot { + background-color: var(--panel); + height: 100%; + max-height: var(--embedMaxHeight, none); + display: flex; + flex-direction: column; +} + +.header { + flex-shrink: 0; + border-bottom: 1px solid var(--divider); +} + +.body { + flex-grow: 1; + overflow-y: auto; +} +</style> diff --git a/packages/frontend-embed/src/components/EmUrl.vue b/packages/frontend-embed/src/components/EmUrl.vue new file mode 100644 index 0000000000000000000000000000000000000000..a96bfdb49311b61666a1d7404a187ed651b37150 --- /dev/null +++ b/packages/frontend-embed/src/components/EmUrl.vue @@ -0,0 +1,96 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<component + :is="self ? EmA : 'a'" ref="el" :class="$style.root" class="_link" :[attr]="self ? props.url.substring(local.length) : props.url" :rel="rel ?? 'nofollow noopener'" :target="target" + @contextmenu.stop="() => {}" +> + <template v-if="!self"> + <span :class="$style.schema">{{ schema }}//</span> + <span :class="$style.hostname">{{ hostname }}</span> + <span v-if="port != ''">:{{ port }}</span> + </template> + <template v-if="pathname === '/' && self"> + <span :class="$style.self">{{ hostname }}</span> + </template> + <span v-if="pathname != ''" :class="$style.pathname">{{ self ? pathname.substring(1) : pathname }}</span> + <span :class="$style.query">{{ query }}</span> + <span :class="$style.hash">{{ hash }}</span> + <i v-if="target === '_blank'" :class="$style.icon" class="ti ti-external-link"></i> +</component> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import { toUnicode as decodePunycode } from 'punycode/'; +import EmA from './EmA.vue'; +import { url as local } from '@/config.js'; + +function safeURIDecode(str: string): string { + try { + return decodeURIComponent(str); + } catch { + return str; + } +} + +const props = withDefaults(defineProps<{ + url: string; + rel?: string; + showUrlPreview?: boolean; +}>(), { + showUrlPreview: true, +}); + +const self = props.url.startsWith(local); +const url = new URL(props.url); +if (!['http:', 'https:'].includes(url.protocol)) throw new Error('invalid url'); +const el = ref(); + +const schema = url.protocol; +const hostname = decodePunycode(url.hostname); +const port = url.port; +const pathname = safeURIDecode(url.pathname); +const query = safeURIDecode(url.search); +const hash = safeURIDecode(url.hash); +const attr = self ? 'to' : 'href'; +const target = self ? null : '_blank'; +</script> + +<style lang="scss" module> +.root { + word-break: break-all; +} + +.icon { + padding-left: 2px; + font-size: .9em; +} + +.self { + font-weight: bold; +} + +.schema { + opacity: 0.5; +} + +.hostname { + font-weight: bold; +} + +.pathname { + opacity: 0.8; +} + +.query { + opacity: 0.5; +} + +.hash { + font-style: italic; +} +</style> diff --git a/packages/frontend-embed/src/components/EmUserName.vue b/packages/frontend-embed/src/components/EmUserName.vue new file mode 100644 index 0000000000000000000000000000000000000000..c0c7c443caebb7045ad59c792aacb31dce2a17ca --- /dev/null +++ b/packages/frontend-embed/src/components/EmUserName.vue @@ -0,0 +1,21 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<EmMfm :text="user.name ?? user.username" :author="user" :plain="true" :nowrap="nowrap" :emojiUrls="user.emojis"/> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import * as Misskey from 'misskey-js'; +import EmMfm from './EmMfm.js'; + +const props = withDefaults(defineProps<{ + user: Misskey.entities.User; + nowrap?: boolean; +}>(), { + nowrap: true, +}); +</script> diff --git a/packages/frontend-embed/src/components/I18n.vue b/packages/frontend-embed/src/components/I18n.vue new file mode 100644 index 0000000000000000000000000000000000000000..b621110ec9ede8f68dc5df979627d27036176dd2 --- /dev/null +++ b/packages/frontend-embed/src/components/I18n.vue @@ -0,0 +1,51 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<render/> +</template> + +<script setup lang="ts" generic="T extends string | ParameterizedString"> +import { computed, h } from 'vue'; +import type { ParameterizedString } from '../../../../locales/index.js'; + +const props = withDefaults(defineProps<{ + src: T; + tag?: string; + // eslint-disable-next-line vue/require-default-prop + textTag?: string; +}>(), { + tag: 'span', +}); + +const slots = defineSlots<T extends ParameterizedString<infer R> ? { [K in R]: () => unknown } : NonNullable<unknown>>(); + +const parsed = computed(() => { + let str = props.src as string; + const value: (string | { arg: string; })[] = []; + for (;;) { + const nextBracketOpen = str.indexOf('{'); + const nextBracketClose = str.indexOf('}'); + + if (nextBracketOpen === -1) { + value.push(str); + break; + } else { + if (nextBracketOpen > 0) value.push(str.substring(0, nextBracketOpen)); + value.push({ + arg: str.substring(nextBracketOpen + 1, nextBracketClose), + }); + } + + str = str.substring(nextBracketClose + 1); + } + + return value; +}); + +const render = () => { + return h(props.tag, parsed.value.map(x => typeof x === 'string' ? (props.textTag ? h(props.textTag, x) : x) : slots[x.arg]())); +}; +</script> diff --git a/packages/frontend-embed/src/config.ts b/packages/frontend-embed/src/config.ts new file mode 100644 index 0000000000000000000000000000000000000000..f9850ba461a67dd682766dab4e3663fdc2020ba9 --- /dev/null +++ b/packages/frontend-embed/src/config.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +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 lang = localStorage.getItem('lang') ?? 'en-US'; +export const langs = _LANGS_; +const preParseLocale = localStorage.getItem('locale'); +export const locale = preParseLocale ? JSON.parse(preParseLocale) : null; +export const instanceName = siteName === 'Misskey' || siteName == null ? host : siteName; +export const debug = localStorage.getItem('debug') === 'true'; diff --git a/packages/frontend-embed/src/custom-emojis.ts b/packages/frontend-embed/src/custom-emojis.ts new file mode 100644 index 0000000000000000000000000000000000000000..d5b40885c1d441064ea410859ce5af8b526a3f33 --- /dev/null +++ b/packages/frontend-embed/src/custom-emojis.ts @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { shallowRef, watch } from 'vue'; +import * as Misskey from 'misskey-js'; +import { misskeyApi, misskeyApiGet } from '@/misskey-api.js'; + +function get(key: string) { + const value = localStorage.getItem(key); + if (value === null) return null; + return JSON.parse(value); +} + +function set(key: string, value: any) { + localStorage.setItem(key, JSON.stringify(value)); +} + +const storageCache = await get('emojis'); +export const customEmojis = shallowRef<Misskey.entities.EmojiSimple[]>(Array.isArray(storageCache) ? storageCache : []); + +export const customEmojisMap = new Map<string, Misskey.entities.EmojiSimple>(); +watch(customEmojis, emojis => { + customEmojisMap.clear(); + for (const emoji of emojis) { + customEmojisMap.set(emoji.name, emoji); + } +}, { immediate: true }); + +export async function fetchCustomEmojis(force = false) { + const now = Date.now(); + + let res; + if (force) { + res = await misskeyApi('emojis', {}); + } else { + const lastFetchedAt = await get('lastEmojisFetchedAt'); + if (lastFetchedAt && (now - lastFetchedAt) < 1000 * 60 * 60) return; + res = await misskeyApiGet('emojis', {}); + } + + customEmojis.value = res.emojis; + set('emojis', res.emojis); + set('lastEmojisFetchedAt', now); +} + +let cachedTags; +export function getCustomEmojiTags() { + if (cachedTags) return cachedTags; + + const tags = new Set(); + for (const emoji of customEmojis.value) { + for (const tag of emoji.aliases) { + tags.add(tag); + } + } + const res = Array.from(tags); + cachedTags = res; + return res; +} diff --git a/packages/frontend-embed/src/di.ts b/packages/frontend-embed/src/di.ts new file mode 100644 index 0000000000000000000000000000000000000000..799bbed598a1abf56ab87b750b09b4f0af841ee1 --- /dev/null +++ b/packages/frontend-embed/src/di.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { InjectionKey } from 'vue'; +import * as Misskey from 'misskey-js'; +import { MediaProxy } from '@@/js/media-proxy.js'; +import type { ParsedEmbedParams } from '@@/js/embed-page.js'; + +export const DI = { + serverMetadata: Symbol() as InjectionKey<Misskey.entities.MetaDetailed>, + embedParams: Symbol() as InjectionKey<ParsedEmbedParams>, + mediaProxy: Symbol() as InjectionKey<MediaProxy>, +}; diff --git a/packages/frontend-embed/src/i18n.ts b/packages/frontend-embed/src/i18n.ts new file mode 100644 index 0000000000000000000000000000000000000000..17e787f9fc17df7e6d481466686b5be930cead0a --- /dev/null +++ b/packages/frontend-embed/src/i18n.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { markRaw } from 'vue'; +import { I18n } from '@@/js/i18n.js'; +import type { Locale } from '../../../locales/index.js'; +import { locale } from '@/config.js'; + +export const i18n = markRaw(new I18n<Locale>(locale, _DEV_)); + +export function updateI18n(newLocale: Locale) { + i18n.locale = newLocale; +} diff --git a/packages/frontend-embed/src/index.html b/packages/frontend-embed/src/index.html new file mode 100644 index 0000000000000000000000000000000000000000..47b0b0e84e5ffec78da1aed4a2fa1001d15a09b6 --- /dev/null +++ b/packages/frontend-embed/src/index.html @@ -0,0 +1,36 @@ +<!-- + SPDX-FileCopyrightText: syuilo and misskey-project + SPDX-License-Identifier: AGPL-3.0-only +--> + +<!-- + 開発モードã®viteã¯ã“ã®ãƒ•ã‚¡ã‚¤ãƒ«ã‚’起点ã«ã‚µãƒ¼ãƒãƒ¼ã‚’èµ·å‹•ã—ã¾ã™ã€‚ + ã“ã®ãƒ•ã‚¡ã‚¤ãƒ«ã«æ›¸ã‹ã‚ŒãŸ [t]js ã®ãƒªãƒ³ã‚¯ã¨ (s)cssã®ãƒªãƒ³ã‚¯ã¨ã€ãã®ä¾å˜é–¢ä¿‚ã«ã‚るファイルã¯ãƒ“ルドã•ã‚Œã¾ã™ +--> + +<!DOCTYPE html> +<html> +<head> + <meta charset="UTF-8" /> + <title>[DEV] Loading...</title> + <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP --> + <meta + http-equiv="Content-Security-Policy" + content="default-src 'self' https://newassets.hcaptcha.com/ https://challenges.cloudflare.com/ http://localhost:7493/; + worker-src 'self'; + script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://challenges.cloudflare.com https://esm.sh; + style-src 'self' 'unsafe-inline'; + img-src 'self' data: blob: www.google.com xn--931a.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000; + media-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000; + connect-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 https://newassets.hcaptcha.com; + frame-src *;" + /> + <meta property="og:site_name" content="[DEV BUILD] Misskey" /> + <meta name="viewport" content="width=device-width, initial-scale=1"> +</head> + +<body> +<div id="misskey_app"></div> +<script type="module" src="./boot.ts"></script> +</body> +</html> diff --git a/packages/frontend-embed/src/misskey-api.ts b/packages/frontend-embed/src/misskey-api.ts new file mode 100644 index 0000000000000000000000000000000000000000..13630590b674e78da83c7814e9a172e522f69480 --- /dev/null +++ b/packages/frontend-embed/src/misskey-api.ts @@ -0,0 +1,99 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; +import { ref } from 'vue'; +import { apiUrl } from '@/config.js'; + +export const pendingApiRequestsCount = ref(0); + +// Implements Misskey.api.ApiClient.request +export function misskeyApi< + ResT = void, + E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints, + P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req'], + _ResT = ResT extends void ? Misskey.api.SwitchCaseResponseType<E, P> : ResT, +>( + endpoint: E, + data: P = {} as any, + signal?: AbortSignal, +): Promise<_ResT> { + if (endpoint.includes('://')) throw new Error('invalid endpoint'); + pendingApiRequestsCount.value++; + + const onFinally = () => { + pendingApiRequestsCount.value--; + }; + + const promise = new Promise<_ResT>((resolve, reject) => { + // Send request + window.fetch(`${apiUrl}/${endpoint}`, { + method: 'POST', + body: JSON.stringify(data), + credentials: 'omit', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + signal, + }).then(async (res) => { + const body = res.status === 204 ? null : await res.json(); + + if (res.status === 200) { + resolve(body); + } else if (res.status === 204) { + resolve(undefined as _ResT); // void -> undefined + } else { + reject(body.error); + } + }).catch(reject); + }); + + promise.then(onFinally, onFinally); + + return promise; +} + +// Implements Misskey.api.ApiClient.request +export function misskeyApiGet< + ResT = void, + E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints, + P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req'], + _ResT = ResT extends void ? Misskey.api.SwitchCaseResponseType<E, P> : ResT, +>( + endpoint: E, + data: P = {} as any, +): Promise<_ResT> { + pendingApiRequestsCount.value++; + + const onFinally = () => { + pendingApiRequestsCount.value--; + }; + + const query = new URLSearchParams(data as any); + + const promise = new Promise<_ResT>((resolve, reject) => { + // Send request + window.fetch(`${apiUrl}/${endpoint}?${query}`, { + method: 'GET', + credentials: 'omit', + cache: 'default', + }).then(async (res) => { + const body = res.status === 204 ? null : await res.json(); + + if (res.status === 200) { + resolve(body); + } else if (res.status === 204) { + resolve(undefined as _ResT); // void -> undefined + } else { + reject(body.error); + } + }).catch(reject); + }); + + promise.then(onFinally, onFinally); + + return promise; +} diff --git a/packages/frontend-embed/src/pages/clip.vue b/packages/frontend-embed/src/pages/clip.vue new file mode 100644 index 0000000000000000000000000000000000000000..6564eecd7584119ed6da5cf626e6f0daf05d3344 --- /dev/null +++ b/packages/frontend-embed/src/pages/clip.vue @@ -0,0 +1,140 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div> + <MkLoading v-if="loading"/> + <EmTimelineContainer v-else-if="clip" :showHeader="embedParams.header"> + <template #header> + <div :class="$style.clipHeader"> + <div :class="$style.headerClipIconRoot"> + <i class="ti ti-paperclip"></i> + </div> + <div :class="$style.headerTitle" @click="top"> + <div class="_nowrap"><a :href="`/clips/${clip.id}`" target="_blank" rel="noopener">{{ clip.name }}</a></div> + <div :class="$style.sub">{{ i18n.tsx.fromX({ x: instanceName }) }}</div> + </div> + <a :href="url" :class="$style.instanceIconLink" target="_blank" rel="noopener noreferrer"> + <img + :class="$style.instanceIcon" + :src="serverMetadata.iconUrl || '/favicon.ico'" + /> + </a> + </div> + </template> + <template #body> + <EmNotes + ref="notesEl" + :pagination="pagination" + :disableAutoLoad="!embedParams.autoload" + :noGap="true" + :ad="false" + /> + </template> + </EmTimelineContainer> + <XNotFound v-else/> +</div> +</template> + +<script setup lang="ts"> +import { ref, computed, shallowRef, inject } from 'vue'; +import * as Misskey from 'misskey-js'; +import { scrollToTop } from '@@/js/scroll.js'; +import type { Paging } from '@/components/EmPagination.vue'; +import EmNotes from '@/components/EmNotes.vue'; +import XNotFound from '@/pages/not-found.vue'; +import EmTimelineContainer from '@/components/EmTimelineContainer.vue'; +import { misskeyApi } from '@/misskey-api.js'; +import { i18n } from '@/i18n.js'; +import { serverMetadata } from '@/server-metadata.js'; +import { url, instanceName } from '@/config.js'; +import { isLink } from '@/to-be-shared/is-link.js'; +import { defaultEmbedParams } from '@@/js/embed-page.js'; +import { DI } from '@/di.js'; + +const props = defineProps<{ + clipId: string; +}>(); + +const embedParams = inject(DI.embedParams, defaultEmbedParams); + +const clip = ref<Misskey.entities.Clip | null>(null); +const pagination = computed(() => ({ + endpoint: 'clips/notes', + params: { + clipId: props.clipId, + }, +} as Paging)); +const loading = ref(true); + +const notesEl = shallowRef<InstanceType<typeof EmNotes> | null>(null); + +function top(ev: MouseEvent) { + const target = ev.target as HTMLElement | null; + if (target && isLink(target)) return; + + if (notesEl.value) { + scrollToTop(notesEl.value.$el as HTMLElement, { behavior: 'smooth' }); + } +} + +misskeyApi('clips/show', { + clipId: props.clipId, +}).then(res => { + clip.value = res; + loading.value = false; +}).catch(err => { + console.error(err); + loading.value = false; +}); +</script> + +<style lang="scss" module> +.clipHeader { + padding: 8px 16px; + display: flex; + min-width: 0; + align-items: center; + gap: var(--margin); + overflow: hidden; + + .headerClipIconRoot { + flex-shrink: 0; + width: 32px; + height: 32px; + line-height: 32px; + font-size: 14px; + text-align: center; + background-color: var(--accentedBg); + color: var(--accent); + border-radius: 50%; + } + + .headerTitle { + flex-grow: 1; + font-weight: 700; + line-height: 1.1; + min-width: 0; + + .sub { + font-size: 0.8em; + font-weight: 400; + opacity: 0.7; + } + } + + .instanceIconLink { + flex-shrink: 0; + display: block; + margin-left: auto; + height: 24px; + } + + .instanceIcon { + height: 24px; + border-radius: 4px; + } +} +</style> diff --git a/packages/frontend-embed/src/pages/not-found.vue b/packages/frontend-embed/src/pages/not-found.vue new file mode 100644 index 0000000000000000000000000000000000000000..bbb03b4e642d25bf820adc01aa7d43c66ba46bc3 --- /dev/null +++ b/packages/frontend-embed/src/pages/not-found.vue @@ -0,0 +1,24 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div> + <div class="_fullinfo"> + <img :src="notFoundImageUrl" class="_ghost"/> + <div>{{ i18n.ts.notFoundDescription }}</div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { inject, computed } from 'vue'; +import { DEFAULT_NOT_FOUND_IMAGE_URL } from '@@/js/const.js'; +import { DI } from '@/di.js'; +import { i18n } from '@/i18n.js'; + +const serverMetadata = inject(DI.serverMetadata)!; + +const notFoundImageUrl = computed(() => serverMetadata?.notFoundImageUrl ?? DEFAULT_NOT_FOUND_IMAGE_URL); +</script> diff --git a/packages/frontend-embed/src/pages/note.vue b/packages/frontend-embed/src/pages/note.vue new file mode 100644 index 0000000000000000000000000000000000000000..86aebe072a12699f164803d621f45c3bd6967444 --- /dev/null +++ b/packages/frontend-embed/src/pages/note.vue @@ -0,0 +1,48 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.noteEmbedRoot"> + <EmLoading v-if="loading"/> + <EmNoteDetailed v-else-if="note" :note="note"/> + <XNotFound v-else/> +</div> +</template> + +<script setup lang="ts"> +import { ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import EmNoteDetailed from '@/components/EmNoteDetailed.vue'; +import EmLoading from '@/components/EmLoading.vue'; +import XNotFound from '@/pages/not-found.vue'; +import { misskeyApi } from '@/misskey-api.js'; + +const props = defineProps<{ + noteId: string; +}>(); + +const note = ref<Misskey.entities.Note | null>(null); +const loading = ref(true); + +// TODO: クライアントå´ã§APIã‚’å©ãã®ã¯äºŒåº¦æ‰‹é–“ãªã®ã§äºˆã‚HTMLã«åŸ‹ã‚込んã§ãŠã +misskeyApi('notes/show', { + noteId: props.noteId, +}).then(res => { + // リモートã®ãƒŽãƒ¼ãƒˆã¯åŸ‹ã‚è¾¼ã¾ã›ãªã„ + if (res.url == null && res.uri == null) { + note.value = res; + } + loading.value = false; +}).catch(err => { + console.error(err); + loading.value = false; +}); +</script> + +<style lang="scss" module> +.noteEmbedRoot { + background-color: var(--panel); +} +</style> diff --git a/packages/frontend-embed/src/pages/tag.vue b/packages/frontend-embed/src/pages/tag.vue new file mode 100644 index 0000000000000000000000000000000000000000..d69555287adb935f108a3d93b95031db0d241aea --- /dev/null +++ b/packages/frontend-embed/src/pages/tag.vue @@ -0,0 +1,125 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div> + <EmTimelineContainer v-if="tag" :showHeader="embedParams.header"> + <template #header> + <div :class="$style.clipHeader"> + <div :class="$style.headerClipIconRoot"> + <i class="ti ti-hash"></i> + </div> + <div :class="$style.headerTitle" @click="top"> + <div class="_nowrap"><a :href="`/tags/${tag}`" target="_blank" rel="noopener">#{{ tag }}</a></div> + <div :class="$style.sub">{{ i18n.tsx.fromX({ x: instanceName }) }}</div> + </div> + <a :href="url" :class="$style.instanceIconLink" target="_blank" rel="noopener noreferrer"> + <img + :class="$style.instanceIcon" + :src="serverMetadata.iconUrl || '/favicon.ico'" + /> + </a> + </div> + </template> + <template #body> + <EmNotes + ref="notesEl" + :pagination="pagination" + :disableAutoLoad="!embedParams.autoload" + :noGap="true" + :ad="false" + /> + </template> + </EmTimelineContainer> + <XNotFound v-else/> +</div> +</template> + +<script setup lang="ts"> +import { computed, shallowRef, inject } from 'vue'; +import { scrollToTop } from '@@/js/scroll.js'; +import type { Paging } from '@/components/EmPagination.vue'; +import EmNotes from '@/components/EmNotes.vue'; +import XNotFound from '@/pages/not-found.vue'; +import EmTimelineContainer from '@/components/EmTimelineContainer.vue'; +import { i18n } from '@/i18n.js'; +import { serverMetadata } from '@/server-metadata.js'; +import { url, instanceName } from '@/config.js'; +import { isLink } from '@/to-be-shared/is-link.js'; +import { DI } from '@/di.js'; +import { defaultEmbedParams } from '@@/js/embed-page.js'; + +const props = defineProps<{ + tag: string; +}>(); + +const embedParams = inject(DI.embedParams, defaultEmbedParams); + +const pagination = computed(() => ({ + endpoint: 'notes/search-by-tag', + params: { + tag: props.tag, + }, +} as Paging)); + +const notesEl = shallowRef<InstanceType<typeof EmNotes> | null>(null); + +function top(ev: MouseEvent) { + const target = ev.target as HTMLElement | null; + if (target && isLink(target)) return; + + if (notesEl.value) { + scrollToTop(notesEl.value.$el as HTMLElement, { behavior: 'smooth' }); + } +} +</script> + +<style lang="scss" module> +.clipHeader { + padding: 8px 16px; + display: flex; + min-width: 0; + align-items: center; + gap: var(--margin); + overflow: hidden; + + .headerClipIconRoot { + flex-shrink: 0; + width: 32px; + height: 32px; + line-height: 32px; + font-size: 14px; + text-align: center; + background-color: var(--accentedBg); + color: var(--accent); + border-radius: 50%; + } + + .headerTitle { + flex-grow: 1; + font-weight: 700; + line-height: 1.1; + min-width: 0; + + .sub { + font-size: 0.8em; + font-weight: 400; + opacity: 0.7; + } + } + + .instanceIconLink { + flex-shrink: 0; + display: block; + margin-left: auto; + height: 24px; + } + + .instanceIcon { + height: 24px; + border-radius: 4px; + } +} +</style> diff --git a/packages/frontend-embed/src/pages/user-timeline.vue b/packages/frontend-embed/src/pages/user-timeline.vue new file mode 100644 index 0000000000000000000000000000000000000000..d590f6e65085d0adf2828e2471ebe4f591022695 --- /dev/null +++ b/packages/frontend-embed/src/pages/user-timeline.vue @@ -0,0 +1,138 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div> + <EmLoading v-if="loading"/> + <EmTimelineContainer v-else-if="user" :showHeader="embedParams.header"> + <template #header> + <div :class="$style.userHeader"> + <a :href="`/@${user.username}`" target="_blank" rel="noopener noreferrer" :class="$style.avatarLink"> + <EmAvatar :class="$style.avatar" :user="user"/> + </a> + <div :class="$style.headerTitle"> + <I18n :src="i18n.ts.noteOf" tag="div" class="_nowrap"> + <template #user> + <a v-if="user != null" :href="`/@${user.username}`" target="_blank" rel="noopener noreferrer"> + <EmUserName :user="user"/> + </a> + <span v-else>{{ i18n.ts.user }}</span> + </template> + </I18n> + <div :class="$style.sub">{{ i18n.tsx.fromX({ x: instanceName }) }}</div> + </div> + <a :href="url" :class="$style.instanceIconLink" target="_blank" rel="noopener noreferrer"> + <img + :class="$style.instanceIcon" + :src="serverMetadata.iconUrl || '/favicon.ico'" + /> + </a> + </div> + </template> + <template #body> + <EmNotes + ref="notesEl" + :pagination="pagination" + :disableAutoLoad="!embedParams.autoload" + :noGap="true" + :ad="false" + /> + </template> + </EmTimelineContainer> + <XNotFound v-else/> +</div> +</template> + +<script setup lang="ts"> +import { ref, computed, shallowRef, inject } from 'vue'; +import * as Misskey from 'misskey-js'; +import type { Paging } from '@/components/EmPagination.vue'; +import EmNotes from '@/components/EmNotes.vue'; +import EmAvatar from '@/components/EmAvatar.vue'; +import EmLoading from '@/components/EmLoading.vue'; +import EmUserName from '@/components/EmUserName.vue'; +import I18n from '@/components/I18n.vue'; +import XNotFound from '@/pages/not-found.vue'; +import EmTimelineContainer from '@/components/EmTimelineContainer.vue'; +import { misskeyApi } from '@/misskey-api.js'; +import { i18n } from '@/i18n.js'; +import { serverMetadata } from '@/server-metadata.js'; +import { url, instanceName } from '@/config.js'; +import { defaultEmbedParams } from '@@/js/embed-page.js'; +import { DI } from '@/di.js'; + +const props = defineProps<{ + userId: string; +}>(); + +const embedParams = inject(DI.embedParams, defaultEmbedParams); + +const user = ref<Misskey.entities.UserLite | null>(null); +const pagination = computed(() => ({ + endpoint: 'users/notes', + params: { + userId: user.value?.id, + }, +} as Paging)); +const loading = ref(true); + +const notesEl = shallowRef<InstanceType<typeof EmNotes> | null>(null); + +misskeyApi('users/show', { + userId: props.userId, +}).then(res => { + user.value = res; + loading.value = false; +}).catch(err => { + console.error(err); + loading.value = false; +}); +</script> + +<style lang="scss" module> +.userHeader { + padding: 8px 16px; + display: flex; + min-width: 0; + align-items: center; + gap: var(--margin); + overflow: hidden; + + .avatarLink { + display: block; + } + + .avatar { + display: inline-block; + width: 32px; + height: 32px; + } + + .headerTitle { + flex-grow: 1; + font-weight: 700; + line-height: 1.1; + min-width: 0; + + .sub { + font-size: 0.8em; + font-weight: 400; + opacity: 0.7; + } + } + + .instanceIconLink { + flex-shrink: 0; + display: block; + margin-left: auto; + height: 24px; + } + + .instanceIcon { + height: 24px; + border-radius: 4px; + } +} +</style> diff --git a/packages/frontend-embed/src/post-message.ts b/packages/frontend-embed/src/post-message.ts new file mode 100644 index 0000000000000000000000000000000000000000..fd8eb8a5d2a094b3c76187db48299b44e56aa41f --- /dev/null +++ b/packages/frontend-embed/src/post-message.ts @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const postMessageEventTypes = [ + 'misskey:embed:ready', + 'misskey:embed:changeHeight', +] as const; + +export type PostMessageEventType = typeof postMessageEventTypes[number]; + +export interface PostMessageEventPayload extends Record<PostMessageEventType, any> { + 'misskey:embed:ready': undefined; + 'misskey:embed:changeHeight': { + height: number; + }; +} + +export type MiPostMessageEvent<T extends PostMessageEventType = PostMessageEventType> = { + type: T; + iframeId?: string; + payload?: PostMessageEventPayload[T]; +} + +let defaultIframeId: string | null = null; + +export function setIframeId(id: string): void { + if (defaultIframeId != null) return; + + if (_DEV_) console.log('setIframeId', id); + defaultIframeId = id; +} + +/** + * 親フレームã«ã‚¤ãƒ™ãƒ³ãƒˆã‚’é€ä¿¡ + */ +export function postMessageToParentWindow<T extends PostMessageEventType = PostMessageEventType>(type: T, payload?: PostMessageEventPayload[T], iframeId: string | null = null): void { + let _iframeId = iframeId; + if (_iframeId == null) { + _iframeId = defaultIframeId; + } + if (_DEV_) console.log('postMessageToParentWindow', type, _iframeId, payload); + window.parent.postMessage({ + type, + iframeId: _iframeId, + payload, + }, '*'); +} diff --git a/packages/frontend-embed/src/server-metadata.ts b/packages/frontend-embed/src/server-metadata.ts new file mode 100644 index 0000000000000000000000000000000000000000..2bd57a0990c50c1dc50cb9321fe0d3e9badf6d17 --- /dev/null +++ b/packages/frontend-embed/src/server-metadata.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { misskeyApi } from '@/misskey-api.js'; + +const providedMetaEl = document.getElementById('misskey_meta'); + +const _serverMetadata = (providedMetaEl && providedMetaEl.textContent) ? JSON.parse(providedMetaEl.textContent) : null; + +// NOTE: devモードã®ã¨ãã—ã‹ _serverMetadata ㌠null ã«ãªã‚‹ã“ã¨ã¯ç„¡ã„ +export const serverMetadata = _serverMetadata ?? await misskeyApi('meta', { + detail: true, +}); diff --git a/packages/frontend-embed/src/style.scss b/packages/frontend-embed/src/style.scss new file mode 100644 index 0000000000000000000000000000000000000000..02008ddbd05b09ea661e010a8e632ac319e5b2bf --- /dev/null +++ b/packages/frontend-embed/src/style.scss @@ -0,0 +1,453 @@ +@charset "utf-8"; + +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +:root { + --radius: 12px; + --marginFull: 14px; + --marginHalf: 10px; + + --margin: var(--marginFull); +} + +html { + background-color: transparent; + color-scheme: light dark; + color: var(--fg); + accent-color: var(--accent); + overflow: clip; + overflow-wrap: break-word; + font-family: 'Hiragino Maru Gothic Pro', "BIZ UDGothic", Roboto, HelveticaNeue, Arial, sans-serif; + font-size: 14px; + line-height: 1.35; + text-size-adjust: 100%; + tab-size: 2; + -webkit-text-size-adjust: 100%; + + &, * { + scrollbar-color: var(--scrollbarHandle) transparent; + scrollbar-width: thin; + + &::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + &::-webkit-scrollbar-track { + background: inherit; + } + + &::-webkit-scrollbar-thumb { + background: var(--scrollbarHandle); + + &:hover { + background: var(--scrollbarHandleHover); + } + + &:active { + background: var(--accent); + } + } + } +} + +html, body { + height: 100%; + touch-action: manipulation; + margin: 0; + padding: 0; + scroll-behavior: smooth; +} + +#misskey_app { + height: 100%; +} + +a { + text-decoration: none; + cursor: pointer; + color: inherit; + tap-highlight-color: transparent; + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; + + &:focus-visible { + outline-offset: 2px; + } + + &:hover { + text-decoration: underline; + } + + &[target="_blank"] { + -webkit-touch-callout: default; + } +} + +rt { + white-space: initial; +} + +:focus-visible { + outline: var(--focus) solid 2px; + outline-offset: -2px; + + &:hover { + text-decoration: none; + } +} + +.ti { + width: 1.28em; + vertical-align: -12%; + line-height: 1em; + + &::before { + font-size: 128%; + } +} + +.ti-fw { + display: inline-block; + text-align: center; +} + +._nowrap { + white-space: pre !important; + word-wrap: normal !important; // https://codeday.me/jp/qa/20190424/690106.html + overflow: hidden; + text-overflow: ellipsis; +} + +._button { + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; + appearance: none; + display: inline-block; + padding: 0; + margin: 0; // for Safari + background: none; + border: none; + cursor: pointer; + color: inherit; + touch-action: manipulation; + tap-highlight-color: transparent; + -webkit-tap-highlight-color: transparent; + font-size: 1em; + font-family: inherit; + line-height: inherit; + max-width: 100%; + + &:disabled { + opacity: 0.5; + cursor: default; + } +} + +._buttonGray { + @extend ._button; + background: var(--buttonBg); + + &:not(:disabled):hover { + background: var(--buttonHoverBg); + } +} + +._buttonPrimary { + @extend ._button; + color: var(--fgOnAccent); + background: var(--accent); + + &:not(:disabled):hover { + background: hsl(from var(--accent) h s calc(l + 5)); + } + + &:not(:disabled):active { + background: hsl(from var(--accent) h s calc(l - 5)); + } +} + +._buttonGradate { + @extend ._buttonPrimary; + color: var(--fgOnAccent); + background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); + + &:not(:disabled):hover { + background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5))); + } + + &:not(:disabled):active { + background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5))); + } +} + +._buttonRounded { + font-size: 0.95em; + padding: 0.5em 1em; + min-width: 100px; + border-radius: 99rem; + + &._buttonPrimary, + &._buttonGradate { + font-weight: 700; + } +} + +._help { + color: var(--accent); + cursor: help; +} + +._textButton { + @extend ._button; + color: var(--accent); + + &:focus-visible { + outline-offset: 2px; + } + + &:not(:disabled):hover { + text-decoration: underline; + } +} + +._panel { + background: var(--panel); + border-radius: var(--radius); + overflow: clip; +} + +._margin { + margin: var(--margin) 0; +} + +._gaps_m { + display: flex; + flex-direction: column; + gap: 1.5em; +} + +._gaps_s { + display: flex; + flex-direction: column; + gap: 0.75em; +} + +._gaps { + display: flex; + flex-direction: column; + gap: var(--margin); +} + +._buttons { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +._buttonsCenter { + @extend ._buttons; + + justify-content: center; +} + +._borderButton { + @extend ._button; + display: block; + width: 100%; + padding: 10px; + box-sizing: border-box; + text-align: center; + border: solid 0.5px var(--divider); + border-radius: var(--radius); + + &:active { + border-color: var(--accent); + } +} + +._popup { + background: var(--popup); + border-radius: var(--radius); + contain: content; +} + +._acrylic { + background: var(--acrylicPanel); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); +} + +._fullinfo { + padding: 64px 32px; + text-align: center; + + > img { + vertical-align: bottom; + height: 128px; + margin-bottom: 16px; + border-radius: 16px; + } +} + +._link { + color: var(--link); +} + +._caption { + font-size: 0.8em; + opacity: 0.7; +} + +._monospace { + font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace !important; +} + +// MFM ----------------------------- + +._mfm_blur_ { + filter: blur(6px); + transition: filter 0.3s; + + &:hover { + filter: blur(0px); + } +} + +.mfm-x2 { + --mfm-zoom-size: 200%; +} + +.mfm-x3 { + --mfm-zoom-size: 400%; +} + +.mfm-x4 { + --mfm-zoom-size: 600%; +} + +.mfm-x2, .mfm-x3, .mfm-x4 { + font-size: var(--mfm-zoom-size); + + .mfm-x2, .mfm-x3, .mfm-x4 { + /* only half effective */ + font-size: calc(var(--mfm-zoom-size) / 2 + 50%); + + .mfm-x2, .mfm-x3, .mfm-x4 { + /* disabled */ + font-size: 100%; + } + } +} + +._mfm_rainbow_fallback_ { + background-image: linear-gradient(to right, rgb(255, 0, 0) 0%, rgb(255, 165, 0) 17%, rgb(255, 255, 0) 33%, rgb(0, 255, 0) 50%, rgb(0, 255, 255) 67%, rgb(0, 0, 255) 83%, rgb(255, 0, 255) 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +@keyframes mfm-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +@keyframes mfm-spinX { + 0% { transform: perspective(128px) rotateX(0deg); } + 100% { transform: perspective(128px) rotateX(360deg); } +} + +@keyframes mfm-spinY { + 0% { transform: perspective(128px) rotateY(0deg); } + 100% { transform: perspective(128px) rotateY(360deg); } +} + +@keyframes mfm-jump { + 0% { transform: translateY(0); } + 25% { transform: translateY(-16px); } + 50% { transform: translateY(0); } + 75% { transform: translateY(-8px); } + 100% { transform: translateY(0); } +} + +@keyframes mfm-bounce { + 0% { transform: translateY(0) scale(1, 1); } + 25% { transform: translateY(-16px) scale(1, 1); } + 50% { transform: translateY(0) scale(1, 1); } + 75% { transform: translateY(0) scale(1.5, 0.75); } + 100% { transform: translateY(0) scale(1, 1); } +} + +// const val = () => `translate(${Math.floor(Math.random() * 20) - 10}px, ${Math.floor(Math.random() * 20) - 10}px)`; +// let css = ''; +// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; } +@keyframes mfm-twitch { + 0% { transform: translate(7px, -2px) } + 5% { transform: translate(-3px, 1px) } + 10% { transform: translate(-7px, -1px) } + 15% { transform: translate(0px, -1px) } + 20% { transform: translate(-8px, 6px) } + 25% { transform: translate(-4px, -3px) } + 30% { transform: translate(-4px, -6px) } + 35% { transform: translate(-8px, -8px) } + 40% { transform: translate(4px, 6px) } + 45% { transform: translate(-3px, 1px) } + 50% { transform: translate(2px, -10px) } + 55% { transform: translate(-7px, 0px) } + 60% { transform: translate(-2px, 4px) } + 65% { transform: translate(3px, -8px) } + 70% { transform: translate(6px, 7px) } + 75% { transform: translate(-7px, -2px) } + 80% { transform: translate(-7px, -8px) } + 85% { transform: translate(9px, 3px) } + 90% { transform: translate(-3px, -2px) } + 95% { transform: translate(-10px, 2px) } + 100% { transform: translate(-2px, -6px) } +} + +// const val = () => `translate(${Math.floor(Math.random() * 6) - 3}px, ${Math.floor(Math.random() * 6) - 3}px) rotate(${Math.floor(Math.random() * 24) - 12}deg)`; +// let css = ''; +// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; } +@keyframes mfm-shake { + 0% { transform: translate(-3px, -1px) rotate(-8deg) } + 5% { transform: translate(0px, -1px) rotate(-10deg) } + 10% { transform: translate(1px, -3px) rotate(0deg) } + 15% { transform: translate(1px, 1px) rotate(11deg) } + 20% { transform: translate(-2px, 1px) rotate(1deg) } + 25% { transform: translate(-1px, -2px) rotate(-2deg) } + 30% { transform: translate(-1px, 2px) rotate(-3deg) } + 35% { transform: translate(2px, 1px) rotate(6deg) } + 40% { transform: translate(-2px, -3px) rotate(-9deg) } + 45% { transform: translate(0px, -1px) rotate(-12deg) } + 50% { transform: translate(1px, 2px) rotate(10deg) } + 55% { transform: translate(0px, -3px) rotate(8deg) } + 60% { transform: translate(1px, -1px) rotate(8deg) } + 65% { transform: translate(0px, -1px) rotate(-7deg) } + 70% { transform: translate(-1px, -3px) rotate(6deg) } + 75% { transform: translate(0px, -2px) rotate(4deg) } + 80% { transform: translate(-2px, -1px) rotate(3deg) } + 85% { transform: translate(1px, -3px) rotate(-10deg) } + 90% { transform: translate(1px, 0px) rotate(3deg) } + 95% { transform: translate(-2px, 0px) rotate(-3deg) } + 100% { transform: translate(2px, 1px) rotate(2deg) } +} + +@keyframes mfm-rubberBand { + from { transform: scale3d(1, 1, 1); } + 30% { transform: scale3d(1.25, 0.75, 1); } + 40% { transform: scale3d(0.75, 1.25, 1); } + 50% { transform: scale3d(1.15, 0.85, 1); } + 65% { transform: scale3d(0.95, 1.05, 1); } + 75% { transform: scale3d(1.05, 0.95, 1); } + to { transform: scale3d(1, 1, 1); } +} + +@keyframes mfm-rainbow { + 0% { filter: hue-rotate(0deg) contrast(150%) saturate(150%); } + 100% { filter: hue-rotate(360deg) contrast(150%) saturate(150%); } +} diff --git a/packages/frontend-embed/src/theme.ts b/packages/frontend-embed/src/theme.ts new file mode 100644 index 0000000000000000000000000000000000000000..050d8cf63ba4c7dc77d88c2f192ebcad57426dd5 --- /dev/null +++ b/packages/frontend-embed/src/theme.ts @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import tinycolor from 'tinycolor2'; +import lightTheme from '@@/themes/_light.json5'; +import darkTheme from '@@/themes/_dark.json5'; +import type { BundledTheme } from 'shiki/themes'; + +export type Theme = { + id: string; + name: string; + author: string; + desc?: string; + base?: 'dark' | 'light'; + props: Record<string, string>; + codeHighlighter?: { + base: BundledTheme; + overrides?: Record<string, any>; + } | { + base: '_none_'; + overrides: Record<string, any>; + }; +}; + +let timeout: number | null = null; + +export function applyTheme(theme: Theme, persist = true) { + if (timeout) window.clearTimeout(timeout); + + document.documentElement.classList.add('_themeChanging_'); + + timeout = window.setTimeout(() => { + document.documentElement.classList.remove('_themeChanging_'); + }, 1000); + + const colorScheme = theme.base === 'dark' ? 'dark' : 'light'; + + // Deep copy + const _theme = JSON.parse(JSON.stringify(theme)); + + if (_theme.base) { + const base = [lightTheme, darkTheme].find(x => x.id === _theme.base); + if (base) _theme.props = Object.assign({}, base.props, _theme.props); + } + + const props = compile(_theme); + + for (const tag of document.head.children) { + if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') { + tag.setAttribute('content', props['htmlThemeColor']); + break; + } + } + + for (const [k, v] of Object.entries(props)) { + document.documentElement.style.setProperty(`--${k}`, v.toString()); + } + + document.documentElement.style.setProperty('color-scheme', colorScheme); +} + +function compile(theme: Theme): Record<string, string> { + function getColor(val: string): tinycolor.Instance { + if (val[0] === '@') { // ref (prop) + return getColor(theme.props[val.substring(1)]); + } else if (val[0] === '$') { // ref (const) + return getColor(theme.props[val]); + } else if (val[0] === ':') { // func + const parts = val.split('<'); + const func = parts.shift().substring(1); + const arg = parseFloat(parts.shift()); + const color = getColor(parts.join('<')); + + switch (func) { + case 'darken': return color.darken(arg); + case 'lighten': return color.lighten(arg); + case 'alpha': return color.setAlpha(arg); + case 'hue': return color.spin(arg); + case 'saturate': return color.saturate(arg); + } + } + + // other case + return tinycolor(val); + } + + const props = {}; + + for (const [k, v] of Object.entries(theme.props)) { + if (k.startsWith('$')) continue; // ignore const + + props[k] = v.startsWith('"') ? v.replace(/^"\s*/, '') : genValue(getColor(v)); + } + + return props; +} + +function genValue(c: tinycolor.Instance): string { + return c.toRgbString(); +} diff --git a/packages/frontend-embed/src/to-be-shared/collapsed.ts b/packages/frontend-embed/src/to-be-shared/collapsed.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ec88a3c657329bc4533be4b7db2e060a38bdb18 --- /dev/null +++ b/packages/frontend-embed/src/to-be-shared/collapsed.ts @@ -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.length >= 5 + ); + + return collapsed; +} diff --git a/packages/frontend-embed/src/to-be-shared/intl-const.ts b/packages/frontend-embed/src/to-be-shared/intl-const.ts new file mode 100644 index 0000000000000000000000000000000000000000..aaa4f0a86edf4898662d1a3f6caeaea88f057c4c --- /dev/null +++ b/packages/frontend-embed/src/to-be-shared/intl-const.ts @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { lang } from '@/config.js'; + +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; diff --git a/packages/frontend-embed/src/to-be-shared/is-link.ts b/packages/frontend-embed/src/to-be-shared/is-link.ts new file mode 100644 index 0000000000000000000000000000000000000000..946f86400e16e75b4f4e73b460b98006363be0ec --- /dev/null +++ b/packages/frontend-embed/src/to-be-shared/is-link.ts @@ -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; +} diff --git a/packages/frontend-embed/src/to-be-shared/worker-multi-dispatch.ts b/packages/frontend-embed/src/to-be-shared/worker-multi-dispatch.ts new file mode 100644 index 0000000000000000000000000000000000000000..6b3fcd938334d03595e57508d2dfe6aeada60085 --- /dev/null +++ b/packages/frontend-embed/src/to-be-shared/worker-multi-dispatch.ts @@ -0,0 +1,82 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +function defaultUseWorkerNumber(prev: number, totalWorkers: number) { + return prev + 1; +} + +export class WorkerMultiDispatch<POST = any, RETURN = any> { + private symbol = Symbol('WorkerMultiDispatch'); + private workers: Worker[] = []; + private terminated = false; + private prevWorkerNumber = 0; + private getUseWorkerNumber = defaultUseWorkerNumber; + 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: typeof defaultUseWorkerNumber = 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; + } + + public addListener(callback: (this: Worker, ev: MessageEvent<RETURN>) => any, options?: boolean | AddEventListenerOptions) { + this.workers.forEach(worker => { + worker.addEventListener('message', callback, options); + }); + } + + 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; + } +} diff --git a/packages/frontend-embed/src/ui.vue b/packages/frontend-embed/src/ui.vue new file mode 100644 index 0000000000000000000000000000000000000000..3b8449dac84c9e35c7b3ad3adee0ef3e38d4787e --- /dev/null +++ b/packages/frontend-embed/src/ui.vue @@ -0,0 +1,96 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + ref="rootEl" + :class="[ + $style.rootForEmbedPage, + { + [$style.rounded]: embedRounded, + [$style.noBorder]: embedNoBorder, + } + ]" + :style="maxHeight > 0 ? { maxHeight: `${maxHeight}px`, '--embedMaxHeight': `${maxHeight}px` } : {}" +> + <div + :class="$style.routerViewContainer" + > + <EmNotePage v-if="page === 'notes'" :noteId="contentId"/> + <EmUserTimelinePage v-else-if="page === 'user-timeline'" :userId="contentId"/> + <EmClipPage v-else-if="page === 'clips'" :clipId="contentId"/> + <EmTagPage v-else-if="page === 'tags'" :tag="contentId"/> + <XNotFound v-else/> + </div> +</div> +</template> + +<script lang="ts" setup> +import { ref, shallowRef, onMounted, onUnmounted, inject } from 'vue'; +import { postMessageToParentWindow } from '@/post-message.js'; +import { DI } from '@/di.js'; +import { defaultEmbedParams } from '@@/js/embed-page.js'; +import EmNotePage from '@/pages/note.vue'; +import EmUserTimelinePage from '@/pages/user-timeline.vue'; +import EmClipPage from '@/pages/clip.vue'; +import EmTagPage from '@/pages/tag.vue'; +import XNotFound from '@/pages/not-found.vue'; + +const page = location.pathname.split('/')[2]; +const contentId = location.pathname.split('/')[3]; +console.log(page, contentId); + +const embedParams = inject(DI.embedParams, defaultEmbedParams); + +//#region Embed Style +const embedRounded = ref(embedParams.rounded); +const embedNoBorder = ref(!embedParams.border); +const maxHeight = ref(embedParams.maxHeight ?? 0); +//#endregion + +//#region Embed Resizer +const rootEl = shallowRef<HTMLElement | null>(null); + +let previousHeight = 0; +const resizeObserver = new ResizeObserver(async () => { + const height = rootEl.value!.scrollHeight + (embedNoBorder.value ? 0 : 2); // border 上下1px + if (Math.abs(previousHeight - height) < 1) return; // 1px未満ã®å¤‰åŒ–ã¯ç„¡è¦– + postMessageToParentWindow('misskey:embed:changeHeight', { + height: (maxHeight.value > 0 && height > maxHeight.value) ? maxHeight.value : height, + }); + previousHeight = height; +}); +onMounted(() => { + resizeObserver.observe(rootEl.value!); +}); +onUnmounted(() => { + resizeObserver.disconnect(); +}); +//#endregion +</script> + +<style lang="scss" module> +.rootForEmbedPage { + box-sizing: border-box; + border: 1px solid var(--divider); + background-color: var(--bg); + overflow: hidden; + position: relative; + height: auto; + + &.rounded { + border-radius: var(--radius); + } + + &.noBorder { + border: none; + } +} + +.routerViewContainer { + container-type: inline-size; + max-height: var(--embedMaxHeight, none); +} +</style> diff --git a/packages/frontend-embed/src/utils.ts b/packages/frontend-embed/src/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..9a2fd0beef7f028a5ccba5cce578a83852a7075b --- /dev/null +++ b/packages/frontend-embed/src/utils.ts @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; +import { url } from '@/config.js'; + +export const acct = (user: Misskey.Acct) => { + return Misskey.acct.toString(user); +}; + +export const userName = (user: Misskey.entities.User) => { + return user.name || user.username; +}; + +export const userPage = (user: Misskey.Acct, path?: string, absolute = false) => { + return `${absolute ? url : ''}/@${acct(user)}${(path ? `/${path}` : '')}`; +}; + +export const notePage = note => { + return `/notes/${note.id}`; +}; diff --git a/packages/frontend-embed/src/workers/draw-blurhash.ts b/packages/frontend-embed/src/workers/draw-blurhash.ts new file mode 100644 index 0000000000000000000000000000000000000000..22de6cd3a8c5aaa227949ce755437ee70441cd6e --- /dev/null +++ b/packages/frontend-embed/src/workers/draw-blurhash.ts @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { render } from 'buraha'; + +const canvas = new OffscreenCanvas(64, 64); + +onmessage = (event) => { + // console.log(event.data); + if (!('id' in event.data && typeof event.data.id === 'string')) { + return; + } + if (!('hash' in event.data && typeof event.data.hash === 'string')) { + return; + } + + render(event.data.hash, canvas); + const bitmap = canvas.transferToImageBitmap(); + postMessage({ id: event.data.id, bitmap }); +}; diff --git a/packages/frontend-embed/src/workers/test-webgl2.ts b/packages/frontend-embed/src/workers/test-webgl2.ts new file mode 100644 index 0000000000000000000000000000000000000000..b203ebe666b8ab1718738c6cb5fb11b2e3d5985a --- /dev/null +++ b/packages/frontend-embed/src/workers/test-webgl2.ts @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +const canvas = globalThis.OffscreenCanvas && new OffscreenCanvas(1, 1); +// 環境ã«ã‚ˆã£ã¦ã¯OffscreenCanvasãŒå˜åœ¨ã—ãªã„ãŸã‚ +// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition +const gl = canvas?.getContext('webgl2'); +if (gl) { + postMessage({ result: true }); +} else { + postMessage({ result: false }); +} diff --git a/packages/frontend-embed/src/workers/tsconfig.json b/packages/frontend-embed/src/workers/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..8ee893046590f2c9aba8e4cbb8a51a722bdfa6b6 --- /dev/null +++ b/packages/frontend-embed/src/workers/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "lib": ["esnext", "webworker"], + } +} diff --git a/packages/frontend-embed/tsconfig.json b/packages/frontend-embed/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..3701343623da5875c54fac3d73f626cc251a6b8f --- /dev/null +++ b/packages/frontend-embed/tsconfig.json @@ -0,0 +1,53 @@ +{ + "compilerOptions": { + "allowJs": true, + "noEmitOnError": false, + "noImplicitAny": false, + "noImplicitReturns": true, + "noUnusedParameters": false, + "noUnusedLocals": false, + "noFallthroughCasesInSwitch": true, + "declaration": false, + "sourceMap": false, + "target": "ES2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "removeComments": false, + "noLib": false, + "strict": true, + "strictNullChecks": true, + "experimentalDecorators": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "isolatedModules": true, + "useDefineForClassFields": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"], + "@@/*": ["../frontend-shared/*"] + }, + "typeRoots": [ + "./@types", + "./node_modules/@types", + "./node_modules/@vue-macros", + "./node_modules" + ], + "types": [ + "vite/client", + ], + "lib": [ + "esnext", + "dom", + "dom.iterable" + ], + "jsx": "preserve" + }, + "compileOnSave": false, + "include": [ + "./**/*.ts", + "./**/*.vue" + ], + "exclude": [ + ".storybook/**/*" + ] +} diff --git a/packages/frontend-embed/vite.config.local-dev.ts b/packages/frontend-embed/vite.config.local-dev.ts new file mode 100644 index 0000000000000000000000000000000000000000..bf2f478887d6e9a8501aaed6e0d0fbad0808c227 --- /dev/null +++ b/packages/frontend-embed/vite.config.local-dev.ts @@ -0,0 +1,96 @@ +import dns from 'dns'; +import { readFile } from 'node:fs/promises'; +import type { IncomingMessage } from 'node:http'; +import { defineConfig } from 'vite'; +import type { UserConfig } from 'vite'; +import * as yaml from 'js-yaml'; +import locales from '../../locales/index.js'; +import { getConfig } from './vite.config.js'; + +dns.setDefaultResultOrder('ipv4first'); + +const defaultConfig = getConfig(); + +const { port } = yaml.load(await readFile('../../.config/default.yml', 'utf-8')); + +const httpUrl = `http://localhost:${port}/`; +const websocketUrl = `ws://localhost:${port}/`; + +// activitypubリクエストã¯Proxyを通ã—ã€ãれ以外ã¯Viteã®é–‹ç™ºã‚µãƒ¼ãƒãƒ¼ã‚’返㙠+function varyHandler(req: IncomingMessage) { + if (req.headers.accept?.includes('application/activity+json')) { + return null; + } + return '/index.html'; +} + +const devConfig: UserConfig = { + // 基本ã®è¨å®šã¯ vite.config.js ã‹ã‚‰å¼•ã継ã + ...defaultConfig, + root: 'src', + publicDir: '../assets', + base: '/embed', + server: { + host: 'localhost', + port: 5174, + proxy: { + '/api': { + changeOrigin: true, + target: httpUrl, + }, + '/assets': httpUrl, + '/static-assets': httpUrl, + '/client-assets': httpUrl, + '/files': httpUrl, + '/twemoji': httpUrl, + '/fluent-emoji': httpUrl, + '/sw.js': httpUrl, + '/streaming': { + target: websocketUrl, + ws: true, + }, + '/favicon.ico': httpUrl, + '/robots.txt': httpUrl, + '/embed.js': httpUrl, + '/identicon': { + target: httpUrl, + rewrite(path) { + return path.replace('@localhost:5173', ''); + }, + }, + '/url': httpUrl, + '/proxy': httpUrl, + '/_info_card_': httpUrl, + '/bios': httpUrl, + '/cli': httpUrl, + '/inbox': httpUrl, + '/emoji/': httpUrl, + '/notes': { + target: httpUrl, + bypass: varyHandler, + }, + '/users': { + target: httpUrl, + bypass: varyHandler, + }, + '/.well-known': { + target: httpUrl, + }, + }, + }, + build: { + ...defaultConfig.build, + rollupOptions: { + ...defaultConfig.build?.rollupOptions, + input: 'index.html', + }, + }, + + define: { + ...defaultConfig.define, + _LANGS_FULL_: JSON.stringify(Object.entries(locales)), + }, +}; + +export default defineConfig(({ command, mode }) => devConfig); + diff --git a/packages/frontend-embed/vite.config.ts b/packages/frontend-embed/vite.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..64e67401c2f81bb6fc726787eca639301aa53e0c --- /dev/null +++ b/packages/frontend-embed/vite.config.ts @@ -0,0 +1,156 @@ +import path from 'path'; +import pluginVue from '@vitejs/plugin-vue'; +import { type UserConfig, defineConfig } from 'vite'; + +import locales from '../../locales/index.js'; +import meta from '../../package.json'; +import packageInfo from './package.json' with { type: 'json' }; +import pluginJson5 from './vite.json5.js'; + +const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.json5', '.svg', '.sass', '.scss', '.css', '.vue']; + +/** + * Misskeyã®ãƒ•ãƒãƒ³ãƒˆã‚¨ãƒ³ãƒ‰ã«ãƒãƒ³ãƒ‰ãƒ«ã›ãšã€CDNãªã©ã‹ã‚‰åˆ¥é€”èªã¿è¾¼ã‚€ãƒªã‚½ãƒ¼ã‚¹ã‚’記述ã™ã‚‹ã€‚ + * CDNを使ã‚ãšã«ãƒãƒ³ãƒ‰ãƒ«ã—ãŸã„å ´åˆã€ä»¥ä¸‹ã®é…列ã‹ã‚‰è©²å½“è¦ç´ を削除orコメントアウトã™ã‚Œã°OK + */ +const externalPackages = [ + // shiki(コードブãƒãƒƒã‚¯ã®ã‚·ãƒ³ã‚¿ãƒƒã‚¯ã‚¹ãƒã‚¤ãƒ©ã‚¤ãƒˆã§ä½¿ç”¨ä¸ï¼‰ã¯ãƒ†ãƒ¼ãƒžãƒ»è¨€èªžã®å®šç¾©ã®å®¹é‡ãŒå¤§ãã„ãŸã‚ã€ãれらã¯CDNã‹ã‚‰èªã¿è¾¼ã‚€ + { + name: 'shiki', + match: /^shiki\/(?<subPkg>(langs|themes))$/, + path(id: string, pattern: RegExp): string { + const match = pattern.exec(id)?.groups; + return match + ? `https://esm.sh/shiki@${packageInfo.dependencies.shiki}/${match['subPkg']}` + : id; + }, + }, +]; + +const hash = (str: string, seed = 0): number => { + let h1 = 0xdeadbeef ^ seed, + h2 = 0x41c6ce57 ^ seed; + for (let i = 0, ch; i < str.length; i++) { + ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); + + return 4294967296 * (2097151 & h2) + (h1 >>> 0); +}; + +const BASE62_DIGITS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + +function toBase62(n: number): string { + if (n === 0) { + return '0'; + } + let result = ''; + while (n > 0) { + result = BASE62_DIGITS[n % BASE62_DIGITS.length] + result; + n = Math.floor(n / BASE62_DIGITS.length); + } + + return result; +} + +export function getConfig(): UserConfig { + return { + base: '/embed_vite/', + + server: { + port: 5174, + }, + + plugins: [ + pluginVue(), + pluginJson5(), + ], + + resolve: { + extensions, + alias: { + '@/': __dirname + '/src/', + '@@/': __dirname + '/../frontend-shared/', + '/client-assets/': __dirname + '/assets/', + '/static-assets/': __dirname + '/../backend/assets/' + }, + }, + + css: { + modules: { + generateScopedName(name, filename, _css): string { + const id = (path.relative(__dirname, filename.split('?')[0]) + '-' + name).replace(/[\\\/\.\?&=]/g, '-').replace(/(src-|vue-)/g, ''); + if (process.env.NODE_ENV === 'production') { + return 'x' + toBase62(hash(id)).substring(0, 4); + } else { + return id; + } + }, + }, + }, + + define: { + _VERSION_: JSON.stringify(meta.version), + _LANGS_: JSON.stringify(Object.entries(locales).map(([k, v]) => [k, v._lang_])), + _ENV_: JSON.stringify(process.env.NODE_ENV), + _DEV_: process.env.NODE_ENV !== 'production', + _PERF_PREFIX_: JSON.stringify('Misskey:'), + __VUE_OPTIONS_API__: false, + __VUE_PROD_DEVTOOLS__: false, + }, + + build: { + target: [ + 'chrome116', + 'firefox116', + 'safari16', + ], + manifest: 'manifest.json', + rollupOptions: { + input: { + app: './src/boot.ts', + }, + external: externalPackages.map(p => p.match), + output: { + manualChunks: { + vue: ['vue'], + }, + chunkFileNames: process.env.NODE_ENV === 'production' ? '[hash:8].js' : '[name]-[hash:8].js', + assetFileNames: process.env.NODE_ENV === 'production' ? '[hash:8][extname]' : '[name]-[hash:8][extname]', + paths(id) { + for (const p of externalPackages) { + if (p.match.test(id)) { + return p.path(id, p.match); + } + } + + return id; + }, + }, + }, + cssCodeSplit: true, + outDir: __dirname + '/../../built/_frontend_embed_vite_', + assetsDir: '.', + emptyOutDir: false, + sourcemap: process.env.NODE_ENV === 'development', + reportCompressedSize: false, + + // https://vitejs.dev/guide/dep-pre-bundling.html#monorepos-and-linked-dependencies + commonjsOptions: { + include: [/misskey-js/, /node_modules/], + }, + }, + + worker: { + format: 'es', + }, + }; +} + +const config = defineConfig(({ command, mode }) => getConfig()); + +export default config; diff --git a/packages/frontend-embed/vite.json5.ts b/packages/frontend-embed/vite.json5.ts new file mode 100644 index 0000000000000000000000000000000000000000..87b67c2142414a94763808ebd83e06cdab3d588c --- /dev/null +++ b/packages/frontend-embed/vite.json5.ts @@ -0,0 +1,48 @@ +// Original: https://github.com/rollup/plugins/tree/8835dd2aed92f408d7dc72d7cc25a9728e16face/packages/json + +import JSON5 from 'json5'; +import { Plugin } from 'rollup'; +import { createFilter, dataToEsm } from '@rollup/pluginutils'; +import { RollupJsonOptions } from '@rollup/plugin-json'; + +// json5 extends SyntaxError with additional fields (without subclassing) +// https://github.com/json5/json5/blob/de344f0619bda1465a6e25c76f1c0c3dda8108d9/lib/parse.js#L1111-L1112 +interface Json5SyntaxError extends SyntaxError { + lineNumber: number; + columnNumber: number; +} + +export default function json5(options: RollupJsonOptions = {}): Plugin { + const filter = createFilter(options.include, options.exclude); + const indent = 'indent' in options ? options.indent : '\t'; + + return { + name: 'json5', + + // eslint-disable-next-line no-shadow + transform(json, id) { + if (id.slice(-6) !== '.json5' || !filter(id)) return null; + + try { + const parsed = JSON5.parse(json); + return { + code: dataToEsm(parsed, { + preferConst: options.preferConst, + compact: options.compact, + namedExports: options.namedExports, + indent, + }), + map: { mappings: '' }, + }; + } catch (err) { + if (!(err instanceof SyntaxError)) { + throw err; + } + const message = 'Could not parse JSON5 file'; + const { lineNumber, columnNumber } = err as Json5SyntaxError; + this.warn({ message, id, loc: { line: lineNumber, column: columnNumber } }); + return null; + } + }, + }; +} diff --git a/packages/frontend-embed/vue-shims.d.ts b/packages/frontend-embed/vue-shims.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..eba994772dd4680f58296de2c82d714a8f044ba3 --- /dev/null +++ b/packages/frontend-embed/vue-shims.d.ts @@ -0,0 +1,6 @@ +/* eslint-disable */ +declare module "*.vue" { + import { defineComponent } from "vue"; + const component: ReturnType<typeof defineComponent>; + export default component; +} diff --git a/packages/frontend-shared/.gitignore b/packages/frontend-shared/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..5f6be09d7c01e1fa28715890bf8a17338cc73bc7 --- /dev/null +++ b/packages/frontend-shared/.gitignore @@ -0,0 +1,2 @@ +/storybook-static +js-built diff --git a/packages/frontend-shared/build.js b/packages/frontend-shared/build.js new file mode 100644 index 0000000000000000000000000000000000000000..17b6da8d30a2ab847aef0181ffcf82db83057f27 --- /dev/null +++ b/packages/frontend-shared/build.js @@ -0,0 +1,106 @@ +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; +import * as esbuild from 'esbuild'; +import { build } from 'esbuild'; +import { globSync } from 'glob'; +import { execa } from 'execa'; + +const _filename = fileURLToPath(import.meta.url); +const _dirname = dirname(_filename); +const _package = JSON.parse(fs.readFileSync(_dirname + '/package.json', 'utf-8')); + +const entryPoints = globSync('./js/**/**.{ts,tsx}'); + +/** @type {import('esbuild').BuildOptions} */ +const options = { + entryPoints, + minify: process.env.NODE_ENV === 'production', + outdir: './js-built', + target: 'es2022', + platform: 'browser', + format: 'esm', + sourcemap: 'linked', +}; + +// js-builté…下をã™ã¹ã¦å‰Šé™¤ã™ã‚‹ +fs.rmSync('./js-built', { recursive: true, force: true }); + +if (process.argv.map(arg => arg.toLowerCase()).includes('--watch')) { + await watchSrc(); +} else { + await buildSrc(); +} + +async function buildSrc() { + console.log(`[${_package.name}] start building...`); + + await build(options) + .then(() => { + console.log(`[${_package.name}] build succeeded.`); + }) + .catch((err) => { + process.stderr.write(err.stderr); + process.exit(1); + }); + + if (process.env.NODE_ENV === 'production') { + console.log(`[${_package.name}] skip building d.ts because NODE_ENV is production.`); + } else { + await buildDts(); + } + + fs.copyFileSync('./js/emojilist.json', './js-built/emojilist.json'); + + console.log(`[${_package.name}] finish building.`); +} + +function buildDts() { + return execa( + 'tsc', + [ + '--project', 'tsconfig.json', + '--outDir', 'js-built', + '--declaration', 'true', + '--emitDeclarationOnly', 'true', + ], + { + stdout: process.stdout, + stderr: process.stderr, + }, + ); +} + +async function watchSrc() { + const plugins = [{ + name: 'gen-dts', + setup(build) { + build.onStart(() => { + console.log(`[${_package.name}] detect changed...`); + }); + build.onEnd(async result => { + if (result.errors.length > 0) { + console.error(`[${_package.name}] watch build failed:`, result); + return; + } + await buildDts(); + }); + }, + }]; + + console.log(`[${_package.name}] start watching...`); + + const context = await esbuild.context({ ...options, plugins }); + await context.watch(); + + await new Promise((resolve, reject) => { + process.on('SIGHUP', resolve); + process.on('SIGINT', resolve); + process.on('SIGTERM', resolve); + process.on('uncaughtException', reject); + process.on('exit', resolve); + }).finally(async () => { + await context.dispose(); + console.log(`[${_package.name}] finish watching.`); + }); +} diff --git a/packages/frontend-shared/eslint.config.js b/packages/frontend-shared/eslint.config.js new file mode 100644 index 0000000000000000000000000000000000000000..a15fb29e37112d6c15e5d972fcf0e56a9ec06280 --- /dev/null +++ b/packages/frontend-shared/eslint.config.js @@ -0,0 +1,96 @@ +import globals from 'globals'; +import tsParser from '@typescript-eslint/parser'; +import parser from 'vue-eslint-parser'; +import pluginVue from 'eslint-plugin-vue'; +import pluginMisskey from '@misskey-dev/eslint-plugin'; +import sharedConfig from '../shared/eslint.config.js'; + +// eslint-disable-next-line import/no-default-export +export default [ + ...sharedConfig, + { + files: ['**/*.vue'], + ...pluginMisskey.configs.typescript, + }, + ...pluginVue.configs['flat/recommended'], + { + files: ['js/**/*.{ts,vue}', '**/*.vue'], + languageOptions: { + globals: { + ...Object.fromEntries(Object.entries(globals.node).map(([key]) => [key, 'off'])), + ...globals.browser, + + // Node.js + module: false, + require: false, + __dirname: false, + + // Misskey + _DEV_: false, + _LANGS_: false, + _VERSION_: false, + _ENV_: false, + _PERF_PREFIX_: false, + _DATA_TRANSFER_DRIVE_FILE_: false, + _DATA_TRANSFER_DRIVE_FOLDER_: false, + _DATA_TRANSFER_DECK_COLUMN_: false, + }, + parser, + parserOptions: { + extraFileExtensions: ['.vue'], + parser: tsParser, + project: ['./tsconfig.json'], + sourceType: 'module', + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + '@typescript-eslint/no-empty-interface': ['error', { + allowSingleExtends: true, + }], + // window ã®ç¦æ¢ç†ç”±: ã‚°ãƒãƒ¼ãƒãƒ«ã‚¹ã‚³ãƒ¼ãƒ—ã¨è¡çªã—ã€äºˆæœŸã›ã¬çµæžœã‚’æ‹›ããŸã‚ + // e ã®ç¦æ¢ç†ç”±: error ã‚„ event ãªã©ã€è¤‡æ•°ã®ã‚ーワードã®é æ–‡å—ã§ã‚り分ã‹ã‚Šã«ãã„ãŸã‚ + 'id-denylist': ['error', 'window', 'e'], + 'no-shadow': ['warn'], + 'vue/attributes-order': ['error', { + alphabetical: false, + }], + 'vue/no-use-v-if-with-v-for': ['error', { + allowUsingIterationVar: false, + }], + 'vue/no-ref-as-operand': 'error', + 'vue/no-multi-spaces': ['error', { + ignoreProperties: false, + }], + 'vue/no-v-html': 'warn', + 'vue/order-in-components': 'error', + 'vue/html-indent': ['warn', 'tab', { + attribute: 1, + baseIndent: 0, + closeBracket: 0, + alignAttributesVertically: true, + ignores: [], + }], + 'vue/html-closing-bracket-spacing': ['warn', { + startTag: 'never', + endTag: 'never', + selfClosingTag: 'never', + }], + 'vue/multi-word-component-names': 'warn', + 'vue/require-v-for-key': 'warn', + 'vue/no-unused-components': 'warn', + 'vue/no-unused-vars': 'warn', + 'vue/no-dupe-keys': 'warn', + 'vue/valid-v-for': 'warn', + 'vue/return-in-computed-property': 'warn', + 'vue/no-setup-props-reactivity-loss': 'warn', + 'vue/max-attributes-per-line': 'off', + 'vue/html-self-closing': 'off', + 'vue/singleline-html-element-content-newline': 'off', + 'vue/v-on-event-hyphenation': ['error', 'never', { + autofix: true, + }], + 'vue/attribute-hyphenation': ['error', 'never'], + }, + }, +]; diff --git a/packages/frontend/src/const.ts b/packages/frontend-shared/js/const.ts similarity index 98% rename from packages/frontend/src/const.ts rename to packages/frontend-shared/js/const.ts index e135bc69a0f3d6497bd450fdb0fadbb279a857c1..8391fb638c5e2e582b12bb49bfe3b0b3fa320340 100644 --- a/packages/frontend/src/const.ts +++ b/packages/frontend-shared/js/const.ts @@ -127,7 +127,7 @@ export const MFM_PARAMS: Record<typeof MFM_TAGS[number], string[]> = { position: ['x=', 'y='], fg: ['color='], bg: ['color='], - border: ['width=', 'style=', 'color=', 'radius=', 'noclip'], + border: ['width=', 'style=', 'color=', 'radius=', 'noclip'], font: ['serif', 'monospace', 'cursive', 'fantasy', 'emoji', 'math'], blur: [], rainbow: ['speed=', 'delay='], diff --git a/packages/frontend-shared/js/embed-page.ts b/packages/frontend-shared/js/embed-page.ts new file mode 100644 index 0000000000000000000000000000000000000000..d5555a98c3b0fae39ba44479cdcd5fa999d87e00 --- /dev/null +++ b/packages/frontend-shared/js/embed-page.ts @@ -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, + }; +} diff --git a/packages/frontend/src/scripts/emoji-base.ts b/packages/frontend-shared/js/emoji-base.ts similarity index 100% rename from packages/frontend/src/scripts/emoji-base.ts rename to packages/frontend-shared/js/emoji-base.ts diff --git a/packages/frontend/src/emojilist.json b/packages/frontend-shared/js/emojilist.json similarity index 100% rename from packages/frontend/src/emojilist.json rename to packages/frontend-shared/js/emojilist.json diff --git a/packages/frontend/src/scripts/emojilist.ts b/packages/frontend-shared/js/emojilist.ts similarity index 96% rename from packages/frontend/src/scripts/emojilist.ts rename to packages/frontend-shared/js/emojilist.ts index 6565feba97d2d1e1b3831cd5fabc882b9d49d149..bde30a864fd0259531399c4e0f8ca7ae6a4acfd0 100644 --- a/packages/frontend/src/scripts/emojilist.ts +++ b/packages/frontend-shared/js/emojilist.ts @@ -12,12 +12,12 @@ export type UnicodeEmojiDef = { } // initial converted from https://github.com/muan/emojilib/commit/242fe68be86ed6536843b83f7e32f376468b38fb -import _emojilist from '../emojilist.json'; +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]], + category: unicodeEmojiCategories[x[2] as number], })); const unicodeEmojisMap = new Map<string, UnicodeEmojiDef>( diff --git a/packages/frontend/src/scripts/extract-avg-color-from-blurhash.ts b/packages/frontend-shared/js/extract-avg-color-from-blurhash.ts similarity index 100% rename from packages/frontend/src/scripts/extract-avg-color-from-blurhash.ts rename to packages/frontend-shared/js/extract-avg-color-from-blurhash.ts diff --git a/packages/frontend/src/scripts/i18n.ts b/packages/frontend-shared/js/i18n.ts similarity index 91% rename from packages/frontend/src/scripts/i18n.ts rename to packages/frontend-shared/js/i18n.ts index b258a2a6781f770e7023e55eac2056005e7a603a..18232691fa7e6069df874782e93e8070a795cc97 100644 --- a/packages/frontend/src/scripts/i18n.ts +++ b/packages/frontend-shared/js/i18n.ts @@ -2,7 +2,10 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ -import type { ILocale, ParameterizedString } from '../../../../locales/index.js'; +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 @@ -32,15 +35,18 @@ type Tsx<T extends ILocale> = { export class I18n<T extends ILocale> { private tsxCache?: Tsx<T>; + private devMode: boolean; + + constructor(public locale: T, devMode = false) { + this.devMode = devMode; - constructor(public locale: T) { //#region BIND this.t = this.t.bind(this); //#endregion } public get ts(): T { - if (_DEV_) { + 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]; @@ -72,7 +78,7 @@ export class I18n<T extends ILocale> { } public get tsx(): Tsx<T> { - if (_DEV_) { + if (this.devMode) { if (this.tsxCache) { return this.tsxCache; } @@ -113,7 +119,7 @@ export class I18n<T extends ILocale> { return () => value; } - return (arg) => { + return (arg: TODO) => { let str = quasis[0]; for (let i = 0; i < expressions.length; i++) { @@ -152,7 +158,7 @@ export class I18n<T extends ILocale> { const value = target[k as keyof typeof target]; if (typeof value === 'object') { - result[k] = build(value as ILocale); + (result as TODO)[k] = build(value as ILocale); } else if (typeof value === 'string') { const quasis: string[] = []; const expressions: string[] = []; @@ -179,7 +185,7 @@ export class I18n<T extends ILocale> { continue; } - result[k] = (arg) => { + (result as TODO)[k] = (arg: TODO) => { let str = quasis[0]; for (let i = 0; i < expressions.length; i++) { @@ -208,9 +214,9 @@ export class I18n<T extends ILocale> { let str: string | ParameterizedString | ILocale = this.locale; for (const k of key.split('.')) { - str = str[k]; + str = (str as TODO)[k]; - if (_DEV_) { + if (this.devMode) { if (typeof str === 'undefined') { console.error(`Unexpected locale key: ${key}`); return key; @@ -219,7 +225,7 @@ export class I18n<T extends ILocale> { } if (args) { - if (_DEV_) { + if (this.devMode) { const missing = Array.from((str as string).matchAll(/\{(\w+)\}/g), ([, parameter]) => parameter).filter(parameter => !Object.hasOwn(args, parameter)); if (missing.length) { @@ -230,7 +236,7 @@ export class I18n<T extends ILocale> { for (const [k, v] of Object.entries(args)) { const search = `{${k}}`; - if (_DEV_) { + if (this.devMode) { if (!(str as string).includes(search)) { console.error(`Unexpected locale parameter: ${k} at ${key}`); } diff --git a/packages/frontend-shared/js/media-proxy.ts b/packages/frontend-shared/js/media-proxy.ts new file mode 100644 index 0000000000000000000000000000000000000000..2837870c9a639d7cda6934c1ea805f87e6837bda --- /dev/null +++ b/packages/frontend-shared/js/media-proxy.ts @@ -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', + })}`; + } +} diff --git a/packages/frontend/src/scripts/scroll.ts b/packages/frontend-shared/js/scroll.ts similarity index 98% rename from packages/frontend/src/scripts/scroll.ts rename to packages/frontend-shared/js/scroll.ts index f0274034b5b3d027824c9041236386d240b577f3..1062e5252febafb4955e1211c87fc5e941f3a76a 100644 --- a/packages/frontend/src/scripts/scroll.ts +++ b/packages/frontend-shared/js/scroll.ts @@ -45,7 +45,7 @@ export function onScrollTop(el: HTMLElement, cb: () => unknown, tolerance = 1, o const container = getScrollContainer(el) ?? window; - const onScroll = ev => { + const onScroll = () => { if (!document.body.contains(el)) return; if (isTopVisible(el, tolerance)) { cb(); @@ -69,7 +69,7 @@ export function onScrollBottom(el: HTMLElement, cb: () => unknown, tolerance = 1 } const containerOrWindow = container ?? window; - const onScroll = ev => { + const onScroll = () => { if (!document.body.contains(el)) return; if (isBottomVisible(el, 1, container)) { cb(); diff --git a/packages/frontend/src/scripts/url.ts b/packages/frontend-shared/js/url.ts similarity index 70% rename from packages/frontend/src/scripts/url.ts rename to packages/frontend-shared/js/url.ts index 5a8265af9e1050d0c7e6ac260828d26f92b2f499..eb830b1eeaec7ac22ab64d01b2fb8ba5058ae246 100644 --- a/packages/frontend/src/scripts/url.ts +++ b/packages/frontend-shared/js/url.ts @@ -8,18 +8,18 @@ * 2. プãƒãƒ‘ティãŒundefinedã®æ™‚ã¯ã‚¯ã‚¨ãƒªã‚’付ã‘ãªã„ * (new URLSearchParams(obj)ã§ã¯ãã“ã¾ã§ä¸å¯§ãªã“ã¨ã‚’ã—ã¦ãã‚Œãªã„) */ -export function query(obj: Record<string, any>): string { +export function query(obj: Record<string, string | number | boolean>): 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>); + .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, query: string): string { - return `${url}${/\?/.test(url) ? url.endsWith('?') ? '' : '&' : '?'}${query}`; +export function appendQuery(url: string, queryString: string): string { + return `${url}${/\?/.test(url) ? url.endsWith('?') ? '' : '&' : '?'}${queryString}`; } export function extractDomain(url: string) { diff --git a/packages/frontend/src/scripts/use-document-visibility.ts b/packages/frontend-shared/js/use-document-visibility.ts similarity index 85% rename from packages/frontend/src/scripts/use-document-visibility.ts rename to packages/frontend-shared/js/use-document-visibility.ts index a8f4d5e03ae2158d609ca78dcc1a8f85f6a4064b..b1197e68dae2bcdedeefb09de5cf7b3202d289b3 100644 --- a/packages/frontend/src/scripts/use-document-visibility.ts +++ b/packages/frontend-shared/js/use-document-visibility.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { onMounted, onUnmounted, ref, Ref } from 'vue'; +import { onMounted, onUnmounted, ref } from 'vue'; +import type { Ref } from 'vue'; export function useDocumentVisibility(): Ref<DocumentVisibilityState> { const visibility = ref(document.visibilityState); diff --git a/packages/frontend/src/scripts/use-interval.ts b/packages/frontend-shared/js/use-interval.ts similarity index 100% rename from packages/frontend/src/scripts/use-interval.ts rename to packages/frontend-shared/js/use-interval.ts diff --git a/packages/frontend-shared/package.json b/packages/frontend-shared/package.json new file mode 100644 index 0000000000000000000000000000000000000000..9981d10dd25d82409262c9da1b3e1db4fdfe27e0 --- /dev/null +++ b/packages/frontend-shared/package.json @@ -0,0 +1,39 @@ +{ + "name": "frontend-shared", + "type": "module", + "main": "./js-built/index.js", + "types": "./js-built/index.d.ts", + "exports": { + ".": { + "import": "./js-built/index.js", + "types": "./js-built/index.d.ts" + }, + "./*": { + "import": "./js-built/*", + "types": "./js-built/*" + } + }, + "scripts": { + "build": "node ./build.js", + "watch": "nodemon -w package.json -e json --exec \"node ./build.js --watch\"", + "eslint": "eslint './**/*.{js,jsx,ts,tsx}'", + "typecheck": "tsc --noEmit", + "lint": "pnpm typecheck && pnpm eslint" + }, + "devDependencies": { + "@types/node": "20.14.12", + "@typescript-eslint/eslint-plugin": "7.17.0", + "@typescript-eslint/parser": "7.17.0", + "esbuild": "0.23.0", + "eslint-plugin-vue": "9.27.0", + "typescript": "5.5.4", + "vue-eslint-parser": "9.4.3" + }, + "files": [ + "js-built" + ], + "dependencies": { + "misskey-js": "workspace:*", + "vue": "3.4.37" + } +} diff --git a/packages/frontend/src/themes/_dark.json5 b/packages/frontend-shared/themes/_dark.json5 similarity index 100% rename from packages/frontend/src/themes/_dark.json5 rename to packages/frontend-shared/themes/_dark.json5 diff --git a/packages/frontend/src/themes/_light.json5 b/packages/frontend-shared/themes/_light.json5 similarity index 100% rename from packages/frontend/src/themes/_light.json5 rename to packages/frontend-shared/themes/_light.json5 diff --git a/packages/frontend/src/themes/d-astro.json5 b/packages/frontend-shared/themes/d-astro.json5 similarity index 100% rename from packages/frontend/src/themes/d-astro.json5 rename to packages/frontend-shared/themes/d-astro.json5 diff --git a/packages/frontend/src/themes/d-botanical.json5 b/packages/frontend-shared/themes/d-botanical.json5 similarity index 100% rename from packages/frontend/src/themes/d-botanical.json5 rename to packages/frontend-shared/themes/d-botanical.json5 diff --git a/packages/frontend/src/themes/d-cherry.json5 b/packages/frontend-shared/themes/d-cherry.json5 similarity index 100% rename from packages/frontend/src/themes/d-cherry.json5 rename to packages/frontend-shared/themes/d-cherry.json5 diff --git a/packages/frontend/src/themes/d-dark.json5 b/packages/frontend-shared/themes/d-dark.json5 similarity index 100% rename from packages/frontend/src/themes/d-dark.json5 rename to packages/frontend-shared/themes/d-dark.json5 diff --git a/packages/frontend/src/themes/d-future.json5 b/packages/frontend-shared/themes/d-future.json5 similarity index 100% rename from packages/frontend/src/themes/d-future.json5 rename to packages/frontend-shared/themes/d-future.json5 diff --git a/packages/frontend/src/themes/d-green-lime.json5 b/packages/frontend-shared/themes/d-green-lime.json5 similarity index 100% rename from packages/frontend/src/themes/d-green-lime.json5 rename to packages/frontend-shared/themes/d-green-lime.json5 diff --git a/packages/frontend/src/themes/d-green-orange.json5 b/packages/frontend-shared/themes/d-green-orange.json5 similarity index 100% rename from packages/frontend/src/themes/d-green-orange.json5 rename to packages/frontend-shared/themes/d-green-orange.json5 diff --git a/packages/frontend/src/themes/d-ice.json5 b/packages/frontend-shared/themes/d-ice.json5 similarity index 100% rename from packages/frontend/src/themes/d-ice.json5 rename to packages/frontend-shared/themes/d-ice.json5 diff --git a/packages/frontend/src/themes/d-persimmon.json5 b/packages/frontend-shared/themes/d-persimmon.json5 similarity index 100% rename from packages/frontend/src/themes/d-persimmon.json5 rename to packages/frontend-shared/themes/d-persimmon.json5 diff --git a/packages/frontend/src/themes/d-u0.json5 b/packages/frontend-shared/themes/d-u0.json5 similarity index 100% rename from packages/frontend/src/themes/d-u0.json5 rename to packages/frontend-shared/themes/d-u0.json5 diff --git a/packages/frontend/src/themes/l-apricot.json5 b/packages/frontend-shared/themes/l-apricot.json5 similarity index 100% rename from packages/frontend/src/themes/l-apricot.json5 rename to packages/frontend-shared/themes/l-apricot.json5 diff --git a/packages/frontend/src/themes/l-botanical.json5 b/packages/frontend-shared/themes/l-botanical.json5 similarity index 100% rename from packages/frontend/src/themes/l-botanical.json5 rename to packages/frontend-shared/themes/l-botanical.json5 diff --git a/packages/frontend/src/themes/l-cherry.json5 b/packages/frontend-shared/themes/l-cherry.json5 similarity index 100% rename from packages/frontend/src/themes/l-cherry.json5 rename to packages/frontend-shared/themes/l-cherry.json5 diff --git a/packages/frontend/src/themes/l-coffee.json5 b/packages/frontend-shared/themes/l-coffee.json5 similarity index 100% rename from packages/frontend/src/themes/l-coffee.json5 rename to packages/frontend-shared/themes/l-coffee.json5 diff --git a/packages/frontend/src/themes/l-light.json5 b/packages/frontend-shared/themes/l-light.json5 similarity index 100% rename from packages/frontend/src/themes/l-light.json5 rename to packages/frontend-shared/themes/l-light.json5 diff --git a/packages/frontend/src/themes/l-rainy.json5 b/packages/frontend-shared/themes/l-rainy.json5 similarity index 100% rename from packages/frontend/src/themes/l-rainy.json5 rename to packages/frontend-shared/themes/l-rainy.json5 diff --git a/packages/frontend/src/themes/l-sushi.json5 b/packages/frontend-shared/themes/l-sushi.json5 similarity index 100% rename from packages/frontend/src/themes/l-sushi.json5 rename to packages/frontend-shared/themes/l-sushi.json5 diff --git a/packages/frontend/src/themes/l-u0.json5 b/packages/frontend-shared/themes/l-u0.json5 similarity index 100% rename from packages/frontend/src/themes/l-u0.json5 rename to packages/frontend-shared/themes/l-u0.json5 diff --git a/packages/frontend/src/themes/l-vivid.json5 b/packages/frontend-shared/themes/l-vivid.json5 similarity index 100% rename from packages/frontend/src/themes/l-vivid.json5 rename to packages/frontend-shared/themes/l-vivid.json5 diff --git a/packages/frontend-shared/tsconfig.json b/packages/frontend-shared/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..fa0b765534bbe9618ae57ca26e0ba87958bf3736 --- /dev/null +++ b/packages/frontend-shared/tsconfig.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "declaration": true, + "declarationMap": true, + "sourceMap": false, + "outDir": "./js-built/", + "removeComments": true, + "resolveJsonModule": true, + "strict": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "esModuleInterop": true, + "typeRoots": [ + "./node_modules/@types" + ], + "lib": [ + "esnext", + "dom" + ] + }, + "include": [ + "js/**/*" + ], + "exclude": [ + "node_modules", + "test/**/*" + ] +} diff --git a/packages/frontend/.storybook/preload-theme.ts b/packages/frontend/.storybook/preload-theme.ts index fb93d7be1353206cf23f12950ecfd8659745db6c..e5573f2ac3a0017f8d9c7a3e3a4f704831ca15e1 100644 --- a/packages/frontend/.storybook/preload-theme.ts +++ b/packages/frontend/.storybook/preload-theme.ts @@ -30,7 +30,7 @@ const keys = [ 'd-u0', ] -await Promise.all(keys.map((key) => readFile(new URL(`../src/themes/${key}.json5`, import.meta.url), 'utf8'))).then((sources) => { +await Promise.all(keys.map((key) => readFile(new URL(`../../frontend-shared/themes/${key}.json5`, import.meta.url), 'utf8'))).then((sources) => { writeFile( new URL('./themes.ts', import.meta.url), `export default ${JSON.stringify( diff --git a/packages/frontend/@types/theme.d.ts b/packages/frontend/@types/theme.d.ts index 0a7281898d9896a1fc82f38c2d61bcb23102c0f2..70afc356c19d9d281a640f686f1974dc1b541805 100644 --- a/packages/frontend/@types/theme.d.ts +++ b/packages/frontend/@types/theme.d.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -declare module '@/themes/*.json5' { +declare module '@@/themes/*.json5' { import { Theme } from '@/scripts/theme.js'; const theme: Theme; diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 1464be18a7faa484bdde0ea6884632f4c044af66..67be7f0598a0adaff7f728f00409d25fd0ce8bcb 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -55,6 +55,7 @@ "misskey-bubble-game": "workspace:*", "misskey-js": "workspace:*", "misskey-reversi": "workspace:*", + "frontend-shared": "workspace:*", "photoswipe": "5.4.4", "punycode": "2.3.1", "rollup": "4.19.1", diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index d86ae18ffeaccf7297b0d47bb5ea507db8d2cdbd..19d30f64cebdddac69b37506fc7a2b2385c7e68a 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -22,7 +22,8 @@ import { getAccountFromId } from '@/scripts/get-account-from-id.js'; import { deckStore } from '@/ui/deck/deck-store.js'; import { miLocalStorage } from '@/local-storage.js'; import { fetchCustomEmojis } from '@/custom-emojis.js'; -import { setupRouter } from '@/router/definition.js'; +import { setupRouter } from '@/router/main.js'; +import { createMainRouter } from '@/router/definition.js'; export async function common(createVue: () => App<Element>) { console.info(`Misskey v${version}`); @@ -239,7 +240,7 @@ export async function common(createVue: () => App<Element>) { const app = createVue(); - setupRouter(app); + setupRouter(app, createMainRouter); if (_DEV_) { app.config.performance = true; diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index 3e7c4f26f807ca078098bb29b93c5708a416c362..b31281dcf286d3c6dbab6dff7f571365d0705aba 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -22,6 +22,7 @@ import { deckStore } from '@/ui/deck/deck-store.js'; import { emojiPicker } from '@/scripts/emoji-picker.js'; import { mainRouter } from '@/router/main.js'; import { type Keymap, makeHotkey } from '@/scripts/hotkey.js'; +import { addCustomEmoji, removeCustomEmojis, updateCustomEmojis } from '@/custom-emojis.js'; export async function mainBoot() { const { isClientUpdated } = await common(() => createApp( @@ -62,6 +63,18 @@ export async function mainBoot() { } }); + stream.on('emojiAdded', emojiData => { + addCustomEmoji(emojiData.emoji); + }); + + stream.on('emojiUpdated', emojiData => { + updateCustomEmojis(emojiData.emojis); + }); + + stream.on('emojiDeleted', emojiData => { + removeCustomEmojis(emojiData.emojis); + }); + for (const plugin of ColdDeviceStorage.get('plugins').filter(p => p.active)) { import('@/plugin.js').then(async ({ install }) => { // Workaround for https://bugs.webkit.org/show_bug.cgi?id=242740 diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue index 932c4ecb2e9c1d2326c07cfc9b16231f83ea813a..f547991369377301b29c115f62775168d33f0778 100644 --- a/packages/frontend/src/components/MkAutocomplete.vue +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -46,17 +46,17 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts"> import { markRaw, ref, shallowRef, computed, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'; import sanitizeHtml from 'sanitize-html'; +import { emojilist, getEmojiName } from '@@/js/emojilist.js'; import contains from '@/scripts/contains.js'; -import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base.js'; +import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@@/js/emoji-base.js'; import { acct } from '@/filters/user.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore } from '@/store.js'; -import { emojilist, getEmojiName } from '@/scripts/emojilist.js'; import { i18n } from '@/i18n.js'; import { miLocalStorage } from '@/local-storage.js'; import { customEmojis } from '@/custom-emojis.js'; -import { MFM_TAGS, MFM_PARAMS } from '@/const.js'; +import { MFM_TAGS, MFM_PARAMS } from '@@/js/const.js'; import { searchEmoji, EmojiDef } from '@/scripts/search-emoji.js'; const lib = emojilist.filter(x => x.category !== 'flags'); diff --git a/packages/frontend/src/components/MkClickerGame.vue b/packages/frontend/src/components/MkClickerGame.vue index 00506fb73513d6e3dba787706f6d409e94da9ad4..9a0a9fba0515297755b5d60f4687a825f9c11290 100644 --- a/packages/frontend/src/components/MkClickerGame.vue +++ b/packages/frontend/src/components/MkClickerGame.vue @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, onMounted, onUnmounted, ref } from 'vue'; import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue'; import * as os from '@/os.js'; -import { useInterval } from '@/scripts/use-interval.js'; +import { useInterval } from '@@/js/use-interval.js'; import * as game from '@/scripts/clicker-game.js'; import number from '@/filters/number.js'; import { claimAchievement } from '@/scripts/achievements.js'; diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue index 1d4c0b6366c802671a1d1ca157176c5d95fcd3d8..716dd92678726f380f73e82a0781851547b30449 100644 --- a/packages/frontend/src/components/MkCode.vue +++ b/packages/frontend/src/components/MkCode.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.codeBlockRoot"> - <button :class="$style.codeBlockCopyButton" class="_button" @click="copy"> + <button v-if="copyButton" :class="$style.codeBlockCopyButton" class="_button" @click="copy"> <i class="ti ti-copy"></i> </button> <Suspense> @@ -32,12 +32,17 @@ import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; -const props = defineProps<{ +const props = withDefaults(defineProps<{ code: string; + forceShow?: boolean; + copyButton?: boolean; lang?: string; -}>(); +}>(), { + copyButton: true, + forceShow: false, +}); -const show = ref(!defaultStore.state.dataSaver.code); +const show = ref(props.forceShow === true ? true : !defaultStore.state.dataSaver.code); const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue')); diff --git a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..51630c427c2604357fc6b1bdaaa37a884cb6f32a --- /dev/null +++ b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue @@ -0,0 +1,412 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkModalWindow + ref="dialogEl" + :width="1000" + :height="600" + :scroll="false" + :withOkButton="false" + @close="cancel()" + @closed="$emit('closed')" +> + <template #header>{{ i18n.ts._embedCodeGen.title }}</template> + + <div :class="$style.embedCodeGenRoot"> + <Transition + mode="out-in" + :enterActiveClass="$style.transition_x_enterActive" + :leaveActiveClass="$style.transition_x_leaveActive" + :enterFromClass="$style.transition_x_enterFrom" + :leaveToClass="$style.transition_x_leaveTo" + > + <div v-if="phase === 'input'" key="input" :class="$style.embedCodeGenInputRoot"> + <div + :class="$style.embedCodeGenPreviewRoot" + > + <MkLoading v-if="iframeLoading" :class="$style.embedCodeGenPreviewSpinner"/> + <div :class="$style.embedCodeGenPreviewWrapper"> + <div class="_acrylic" :class="$style.embedCodeGenPreviewTitle">{{ i18n.ts.preview }}</div> + <div ref="resizerRootEl" :class="$style.embedCodeGenPreviewResizerRoot" inert> + <div + :class="$style.embedCodeGenPreviewResizer" + :style="{ transform: iframeStyle }" + > + <iframe + ref="iframeEl" + :src="embedPreviewUrl" + :class="$style.embedCodeGenPreviewIframe" + :style="{ height: `${iframeHeight}px` }" + @load="iframeOnLoad" + ></iframe> + </div> + </div> + </div> + </div> + <div :class="$style.embedCodeGenSettings" class="_gaps"> + <MkInput v-if="isEmbedWithScrollbar" v-model="maxHeight" type="number" :min="0"> + <template #label>{{ i18n.ts._embedCodeGen.maxHeight }}</template> + <template #suffix>px</template> + <template #caption>{{ i18n.ts._embedCodeGen.maxHeightDescription }}</template> + </MkInput> + <MkSelect v-model="colorMode"> + <template #label>{{ i18n.ts.theme }}</template> + <option value="auto">{{ i18n.ts.syncDeviceDarkMode }}</option> + <option value="light">{{ i18n.ts.light }}</option> + <option value="dark">{{ i18n.ts.dark }}</option> + </MkSelect> + <MkSwitch v-if="isEmbedWithScrollbar" v-model="header">{{ i18n.ts._embedCodeGen.header }}</MkSwitch> + <MkSwitch v-model="rounded">{{ i18n.ts._embedCodeGen.rounded }}</MkSwitch> + <MkSwitch v-model="border">{{ i18n.ts._embedCodeGen.border }}</MkSwitch> + <MkInfo v-if="isEmbedWithScrollbar && (!maxHeight || maxHeight <= 0)" warn>{{ i18n.ts._embedCodeGen.maxHeightWarn }}</MkInfo> + <MkInfo v-if="typeof maxHeight === 'number' && (maxHeight <= 0 || maxHeight > 700)">{{ i18n.ts._embedCodeGen.previewIsNotActual }}</MkInfo> + <div class="_buttons"> + <MkButton :disabled="iframeLoading" @click="applyToPreview">{{ i18n.ts._embedCodeGen.applyToPreview }}</MkButton> + <MkButton :disabled="iframeLoading" primary @click="generate">{{ i18n.ts._embedCodeGen.generateCode }} <i class="ti ti-arrow-right"></i></MkButton> + </div> + </div> + </div> + <div v-else-if="phase === 'result'" key="result" :class="$style.embedCodeGenResultRoot"> + <div :class="$style.embedCodeGenResultWrapper" class="_gaps"> + <div class="_gaps_s"> + <div :class="$style.embedCodeGenResultHeadingIcon"><i class="ti ti-check"></i></div> + <div :class="$style.embedCodeGenResultHeading">{{ i18n.ts._embedCodeGen.codeGenerated }}</div> + <div :class="$style.embedCodeGenResultDescription">{{ i18n.ts._embedCodeGen.codeGeneratedDescription }}</div> + </div> + <div class="_gaps_s"> + <MkCode :code="result" lang="html" :forceShow="true" :copyButton="false" :class="$style.embedCodeGenResultCode"/> + <MkButton :class="$style.embedCodeGenResultButtons" rounded primary @click="doCopy"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton> + </div> + <MkButton :class="$style.embedCodeGenResultButtons" rounded transparent @click="close">{{ i18n.ts.close }}</MkButton> + </div> + </div> + </Transition> + </div> +</MkModalWindow> +</template> + +<script setup lang="ts"> +import { shallowRef, ref, computed, nextTick, onMounted, onDeactivated, onUnmounted } from 'vue'; +import type { EmbeddableEntity, EmbedParams } from '@@/js/embed-page.js'; +import MkModalWindow from '@/components/MkModalWindow.vue'; + +import MkInput from '@/components/MkInput.vue'; +import MkSelect from '@/components/MkSelect.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import MkButton from '@/components/MkButton.vue'; + +import MkCode from '@/components/MkCode.vue'; +import MkInfo from '@/components/MkInfo.vue'; + +import * as os from '@/os.js'; +import { i18n } from '@/i18n.js'; +import { url } from '@/config.js'; +import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { normalizeEmbedParams, getEmbedCode } from '@/scripts/get-embed-code.js'; +import { embedRouteWithScrollbar } from '@@/js/embed-page.js'; + +const emit = defineEmits<{ + (ev: 'ok'): void; + (ev: 'cancel'): void; + (ev: 'closed'): void; +}>(); + +const props = defineProps<{ + entity: EmbeddableEntity; + id: string; + params?: EmbedParams; +}>(); + +//#region Modalã®åˆ¶å¾¡ +const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>(); + +function cancel() { + emit('cancel'); + dialogEl.value?.close(); +} + +function close() { + dialogEl.value?.close(); +} + +const phase = ref<'input' | 'result'>('input'); +//#endregion + +//#region 埋ã‚è¾¼ã¿URL生æˆãƒ»ã‚«ã‚¹ã‚¿ãƒžã‚¤ã‚º + +// 本URL生æˆç”¨params +const paramsForUrl = computed<EmbedParams>(() => ({ + header: header.value, + maxHeight: typeof maxHeight.value === 'number' ? Math.max(0, maxHeight.value) : undefined, + colorMode: colorMode.value === 'auto' ? undefined : colorMode.value, + rounded: rounded.value, + border: border.value, +})); + +// プレビュー用params(手動ã§æ›´æ–°ã‚’掛ã‘ã‚‹ã®ã§ref) +const paramsForPreview = ref<EmbedParams>(props.params ?? {}); + +const embedPreviewUrl = computed(() => { + const paramClass = new URLSearchParams(normalizeEmbedParams(paramsForPreview.value)); + if (paramClass.has('maxHeight')) { + const maxHeight = parseInt(paramClass.get('maxHeight')!); + paramClass.set('maxHeight', maxHeight === 0 ? '500' : Math.min(maxHeight, 700).toString()); // プレビューã§ã‚ã¾ã‚Šã«ã‚‚縮å°ã•ã‚Œã‚‹ã¨è¦‹ã¥ã‚‰ã„ãŸã‚ã€700pxã¾ã§ã«åˆ¶é™ + } + return `${url}/embed/${props.entity}/${props.id}${paramClass.toString() ? '?' + paramClass.toString() : ''}`; +}); + +const isEmbedWithScrollbar = computed(() => embedRouteWithScrollbar.includes(props.entity)); +const header = ref(props.params?.header ?? true); +const maxHeight = ref(props.params?.maxHeight !== 0 ? props.params?.maxHeight ?? undefined : 500); + +const colorMode = ref<'light' | 'dark' | 'auto'>(props.params?.colorMode ?? 'auto'); +const rounded = ref(props.params?.rounded ?? true); +const border = ref(props.params?.border ?? true); + +function applyToPreview() { + const currentPreviewUrl = embedPreviewUrl.value; + + paramsForPreview.value = { + header: header.value, + maxHeight: typeof maxHeight.value === 'number' ? Math.max(0, maxHeight.value) : undefined, + colorMode: colorMode.value === 'auto' ? undefined : colorMode.value, + rounded: rounded.value, + border: border.value, + }; + + nextTick(() => { + if (currentPreviewUrl === embedPreviewUrl.value) { + // URLãŒå¤‰ã‚らãªãã¦ã‚‚リãƒãƒ¼ãƒ‰ + iframeEl.value?.contentWindow?.location.reload(); + } + }); +} + +const result = ref(''); + +function generate() { + result.value = getEmbedCode(`/embed/${props.entity}/${props.id}`, paramsForUrl.value); + phase.value = 'result'; +} + +function doCopy() { + copyToClipboard(result.value); + os.success(); +} +//#endregion + +//#region プレビューã®ãƒªã‚µã‚¤ã‚º +const resizerRootEl = shallowRef<HTMLDivElement>(); +const iframeLoading = ref(true); +const iframeEl = shallowRef<HTMLIFrameElement>(); +const iframeHeight = ref(0); +const iframeScale = ref(1); +const iframeStyle = computed(() => { + return `translate(-50%, -50%) scale(${iframeScale.value})`; +}); +const resizeObserver = new ResizeObserver(() => { + calcScale(); +}); + +function iframeOnLoad() { + iframeEl.value?.contentWindow?.addEventListener('beforeunload', () => { + iframeLoading.value = true; + nextTick(() => { + iframeHeight.value = 0; + iframeScale.value = 1; + }); + }); +} + +function windowEventHandler(event: MessageEvent) { + if (event.source !== iframeEl.value?.contentWindow) { + return; + } + if (event.data.type === 'misskey:embed:ready') { + iframeEl.value!.contentWindow?.postMessage({ + type: 'misskey:embedParent:registerIframeId', + payload: { + iframeId: 'embedCodeGen', // åŒã˜ã‚¿ã‚¤ãƒŸãƒ³ã‚°ã§è¤‡æ•°ã®embed iframeãŒã‚ã‚‹éš›ã®åŒºåˆ¥ç”¨ãªã®ã§ã“ã“ã§ã¯ãªã‚“ã§ã‚‚ã„ã„ + }, + }); + } + if (event.data.type === 'misskey:embed:changeHeight') { + iframeHeight.value = event.data.payload.height; + nextTick(() => { + calcScale(); + iframeLoading.value = false; // åˆå›žã®é«˜ã•å¤‰æ›´ã¾ã§å¾…㤠+ }); + } +} + +function calcScale() { + if (!resizerRootEl.value) return; + const previewWidth = resizerRootEl.value.clientWidth - 40; // å·¦å³ã®ä½™ç™½ 20pxãšã¤ + const previewHeight = resizerRootEl.value.clientHeight - 40; // 上下ã®ä½™ç™½ 20pxãšã¤ + const iframeWidth = 500; + const scale = Math.min(previewWidth / iframeWidth, previewHeight / iframeHeight.value, 1); // 拡大ã¯ã—ãªã„ã®ã§1を上é™ã« + iframeScale.value = scale; +} + +onMounted(() => { + window.addEventListener('message', windowEventHandler); + if (!resizerRootEl.value) return; + resizeObserver.observe(resizerRootEl.value); +}); + +function reset() { + window.removeEventListener('message', windowEventHandler); + resizeObserver.disconnect(); + + // プレビューã®ãƒªã‚»ãƒƒãƒˆ + iframeHeight.value = 0; + iframeScale.value = 1; + iframeLoading.value = true; + result.value = ''; + phase.value = 'input'; +} + +onDeactivated(() => { + reset(); +}); + +onUnmounted(() => { + reset(); +}); +//#endregion +</script> + +<style module> +.transition_x_enterActive, +.transition_x_leaveActive { + transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1); +} +.transition_x_enterFrom { + opacity: 0; + transform: translateX(50px); +} +.transition_x_leaveTo { + opacity: 0; + transform: translateX(-50px); +} + +.embedCodeGenRoot { + container-type: inline-size; + height: 100%; +} + +.embedCodeGenInputRoot { + height: 100%; + display: grid; + grid-template-columns: 1fr 400px; +} + +.embedCodeGenPreviewRoot { + position: relative; + background-color: var(--bg); + cursor: not-allowed; +} + +.embedCodeGenPreviewWrapper { + display: flex; + flex-direction: column; + height: 100%; + pointer-events: none; + user-select: none; + -webkit-user-drag: none; +} + +.embedCodeGenPreviewTitle { + position: absolute; + z-index: 100; + top: 8px; + left: 8px; + padding: 6px 10px; + border-radius: 6px; + font-size: 85%; +} + +.embedCodeGenPreviewSpinner { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + pointer-events: none; + user-select: none; + -webkit-user-drag: none; +} + +.embedCodeGenPreviewResizerRoot { + position: relative; + flex: 1 0; +} + +.embedCodeGenPreviewResizer { + position: absolute; + top: 50%; + left: 50%; +} + +.embedCodeGenPreviewIframe { + display: block; + border: none; + width: 500px; + color-scheme: light dark; +} + +.embedCodeGenSettings { + padding: 24px; + overflow-y: scroll; +} + +.embedCodeGenResultRoot { + box-sizing: border-box; + padding: 24px; + height: 100%; + max-width: 700px; + margin: 0 auto; + display: flex; + align-items: center; +} + +.embedCodeGenResultHeading { + text-align: center; + font-size: 1.2em; +} + +.embedCodeGenResultHeadingIcon { + margin: 0 auto; + background-color: var(--accentedBg); + color: var(--accent); + text-align: center; + height: 64px; + width: 64px; + font-size: 24px; + line-height: 64px; + border-radius: 50%; +} + +.embedCodeGenResultDescription { + text-align: center; + white-space: pre-wrap; +} + +.embedCodeGenResultWrapper, +.embedCodeGenResultCode { + width: 100%; +} + +.embedCodeGenResultButtons { + margin: 0 auto; +} + +@container (max-width: 800px) { + .embedCodeGenInputRoot { + grid-template-columns: 1fr; + grid-template-rows: 1fr 1fr; + } +} +</style> diff --git a/packages/frontend/src/components/MkEmojiPicker.section.vue b/packages/frontend/src/components/MkEmojiPicker.section.vue index c13164c2968d11b50158c5f228fd9c954261003c..fca7aa2f4ec149a32ea5429583e9a1baac891c46 100644 --- a/packages/frontend/src/components/MkEmojiPicker.section.vue +++ b/packages/frontend/src/components/MkEmojiPicker.section.vue @@ -62,7 +62,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, computed, Ref } from 'vue'; -import { CustomEmojiFolderTree, getEmojiName } from '@/scripts/emojilist.js'; +import { CustomEmojiFolderTree, getEmojiName } from '@@/js/emojilist.js'; import { i18n } from '@/i18n.js'; import { customEmojis } from '@/custom-emojis.js'; import MkEmojiPickerSection from '@/components/MkEmojiPicker.section.vue'; diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index 4a3ed69f470d9cf2c132c14d0c5449bb3e3681d5..5ba175fc3504a9a03bd9b6146875b26d5254397f 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -117,7 +117,6 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, shallowRef, computed, watch, onMounted } from 'vue'; import * as Misskey from 'misskey-js'; -import XSection from '@/components/MkEmojiPicker.section.vue'; import { emojilist, emojiCharByCategory, @@ -126,7 +125,8 @@ import { getEmojiName, CustomEmojiFolderTree, getUnicodeEmoji, -} from '@/scripts/emojilist.js'; +} from '@@/js/emojilist.js'; +import XSection from '@/components/MkEmojiPicker.section.vue'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import * as os from '@/os.js'; import { isTouchUsing } from '@/scripts/touch.js'; diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue index 8d301f16bd4e4e279d46af23b6cc802350f9d93b..eeecf052af239eb535d3d93d5c9ed36fc24123d2 100644 --- a/packages/frontend/src/components/MkImgWithBlurhash.vue +++ b/packages/frontend/src/components/MkImgWithBlurhash.vue @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only import DrawBlurhash from '@/workers/draw-blurhash?worker'; import TestWebGL2 from '@/workers/test-webgl2?worker'; import { WorkerMultiDispatch } from '@/scripts/worker-multi-dispatch.js'; -import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash.js'; +import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js'; const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => { // テスト環境㧠Web Worker インスタンスã¯ä½œæˆã§ããªã„ diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue index e695564f921f748933750071edeaf83eadef4048..4c2fc1ba00da534e3e6d4cba9962c22c9d8c56aa 100644 --- a/packages/frontend/src/components/MkInput.vue +++ b/packages/frontend/src/components/MkInput.vue @@ -47,7 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { onMounted, onUnmounted, nextTick, ref, shallowRef, watch, computed, toRefs } from 'vue'; import { debounce } from 'throttle-debounce'; import MkButton from '@/components/MkButton.vue'; -import { useInterval } from '@/scripts/use-interval.js'; +import { useInterval } from '@@/js/use-interval.js'; import { i18n } from '@/i18n.js'; import { Autocomplete, SuggestionType } from '@/scripts/autocomplete.js'; diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue index 2300802dcff86e595bceb43bb94402130207058e..4a4a99be253cfe85febb474ecd21580866f82adb 100644 --- a/packages/frontend/src/components/MkMediaList.vue +++ b/packages/frontend/src/components/MkMediaList.vue @@ -37,7 +37,7 @@ import XBanner from '@/components/MkMediaBanner.vue'; import XImage from '@/components/MkMediaImage.vue'; import XVideo from '@/components/MkMediaVideo.vue'; import * as os from '@/os.js'; -import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; +import { FILE_TYPE_BROWSERSAFE } from '@@/js/const.js'; import { defaultStore } from '@/store.js'; import { focusParent } from '@/scripts/focus.js'; diff --git a/packages/frontend/src/components/MkMiniChart.vue b/packages/frontend/src/components/MkMiniChart.vue index f2f2bf47a8b59a1bf0d26be87ea8f10c7dd34b70..1b6f6cef31e45a25b60a37d063eda62d04ca7a27 100644 --- a/packages/frontend/src/components/MkMiniChart.vue +++ b/packages/frontend/src/components/MkMiniChart.vue @@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { watch, ref } from 'vue'; import { v4 as uuid } from 'uuid'; import tinycolor from 'tinycolor2'; -import { useInterval } from '@/scripts/use-interval.js'; +import { useInterval } from '@@/js/use-interval.js'; const props = defineProps<{ src: number[]; diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 4caafe54bf91fdd99bad78992ab95b9ff15251fe..2927a46977b6fb206cb6f9a715817bf2008c24be 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -627,7 +627,7 @@ function emitUpdReaction(emoji: string, delta: number) { // 今度ã¯ãã®å‡¦ç†è‡ªä½“ãŒãƒ‘フォーマンス低下ã®åŽŸå› ã«ãªã‚‰ãªã„ã‹æ‡¸å¿µã•ã‚Œã‚‹ã€‚ã¾ãŸã€è¢«ãƒªã‚¢ã‚¯ã‚·ãƒ§ãƒ³ã§ã‚‚高ã•ã¯å¤‰åŒ–ã™ã‚‹ãŸã‚ã€ã‚„ã¯ã‚Šå¤šå°‘ã®ã‚ºãƒ¬ã¯ç”Ÿã˜ã‚‹ // 一度レンダリングã•ã‚ŒãŸè¦ç´ ã¯ãƒ–ラウザãŒã‚ˆã—ãªã«ã‚µã‚¤ã‚ºã‚’覚ãˆã¦ãŠã„ã¦ãれるよã†ãªå®Ÿè£…ã«ãªã‚‹ã¾ã§å¾…ã£ãŸæ–¹ãŒè‰¯ã•ãã†(ãªã‚‹ã®ã‹ï¼Ÿ) //content-visibility: auto; - //contain-intrinsic-size: 0 128px; + //contain-intrinsic-size: 0 128px; &:focus-visible { outline: none; diff --git a/packages/frontend/src/components/MkNotificationSelectWindow.vue b/packages/frontend/src/components/MkNotificationSelectWindow.vue index 71b38d99ed9c92897842402b5ca269ce79634b8c..47a9c79e4551c2f72a97f51ea58a7d5f1ffa82a6 100644 --- a/packages/frontend/src/components/MkNotificationSelectWindow.vue +++ b/packages/frontend/src/components/MkNotificationSelectWindow.vue @@ -35,7 +35,7 @@ import MkSwitch from './MkSwitch.vue'; import MkInfo from './MkInfo.vue'; import MkButton from './MkButton.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; -import { notificationTypes } from '@/const.js'; +import { notificationTypes } from '@@/js/const.js'; import { i18n } from '@/i18n.js'; type TypesMap = Record<typeof notificationTypes[number], Ref<boolean>> diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index 389987338d0eb789b88cc878eafb2bc96f213fa1..d67616e6b2036f834133a675d312a80ea22fa326 100644 --- a/packages/frontend/src/components/MkNotifications.vue +++ b/packages/frontend/src/components/MkNotifications.vue @@ -31,7 +31,7 @@ import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; import MkNote from '@/components/MkNote.vue'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; -import { notificationTypes } from '@/const.js'; +import { notificationTypes } from '@@/js/const.js'; import { infoImageUrl } from '@/instance.js'; import { defaultStore } from '@/store.js'; import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue index bd86b015916102e2f1aeec1f95f4c62e28cbac5e..8049f88051b8862a5b6f061214579a636835e7a5 100644 --- a/packages/frontend/src/components/MkPageWindow.vue +++ b/packages/frontend/src/components/MkPageWindow.vue @@ -40,7 +40,7 @@ import { i18n } from '@/i18n.js'; import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; import { openingWindowsCount } from '@/os.js'; import { claimAchievement } from '@/scripts/achievements.js'; -import { getScrollContainer } from '@/scripts/scroll.js'; +import { getScrollContainer } from '@@/js/scroll.js'; import { useRouterFactory } from '@/router/supplier.js'; import { mainRouter } from '@/router/main.js'; diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index 62a85389ad967b57a2693e21d96ad5607a2a62a3..d30f915c552394980fc11e21611b7f461abdeb30 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -45,10 +45,10 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts"> import { computed, ComputedRef, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, shallowRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; +import { useDocumentVisibility } from '@@/js/use-document-visibility.js'; +import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@@/js/scroll.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; -import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@/scripts/scroll.js'; -import { useDocumentVisibility } from '@/scripts/use-document-visibility.js'; import { defaultStore } from '@/store.js'; import { MisskeyEntity } from '@/types/date-separated-list.js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/components/MkPoll.vue b/packages/frontend/src/components/MkPoll.vue index 72bd8f4f6c4067c1efa28dd667a4c91d4a24109e..8e230cce4f7d2e488f9765363a7a8b0ea650be4e 100644 --- a/packages/frontend/src/components/MkPoll.vue +++ b/packages/frontend/src/components/MkPoll.vue @@ -29,14 +29,14 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, ref } from 'vue'; import * as Misskey from 'misskey-js'; +import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; import { sum } from '@/scripts/array.js'; import { pleaseLogin } from '@/scripts/please-login.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { host } from '@/config.js'; -import { useInterval } from '@/scripts/use-interval.js'; -import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; +import { useInterval } from '@@/js/use-interval.js'; const props = defineProps<{ noteId: string; @@ -83,10 +83,10 @@ if (props.poll.expiresAt) { } const vote = async (id) => { - pleaseLogin(undefined, pleaseLoginContext.value); - if (props.readOnly || closed.value || isVoted.value) return; + pleaseLogin(undefined, pleaseLoginContext.value); + const { canceled } = await os.confirm({ type: 'question', text: i18n.tsx.voteConfirm({ choice: props.poll.choices[id].text }), @@ -145,7 +145,7 @@ const vote = async (id) => { .done { .choice { - cursor: default; + cursor: initial; } } </style> diff --git a/packages/frontend/src/components/MkPullToRefresh.vue b/packages/frontend/src/components/MkPullToRefresh.vue index e0d0b561beb08f961fca6b1bb4a15928c6e66a4f..4fb4c6fe56a305cb1718bdaec4077cdaab9b9e8e 100644 --- a/packages/frontend/src/components/MkPullToRefresh.vue +++ b/packages/frontend/src/components/MkPullToRefresh.vue @@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, onUnmounted, ref, shallowRef } from 'vue'; import { i18n } from '@/i18n.js'; -import { getScrollContainer } from '@/scripts/scroll.js'; +import { getScrollContainer } from '@@/js/scroll.js'; import { isHorizontalSwipeSwiping } from '@/scripts/touch.js'; const SCROLL_STOP = 10; diff --git a/packages/frontend/src/components/MkReactionsViewer.details.vue b/packages/frontend/src/components/MkReactionsViewer.details.vue index 60118fadd2a4f87c04f01afe2963d0cea949a664..3dd02b261c7fa09a3a8c6dddea848fffb3c35b0a 100644 --- a/packages/frontend/src/components/MkReactionsViewer.details.vue +++ b/packages/frontend/src/components/MkReactionsViewer.details.vue @@ -23,9 +23,9 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { } from 'vue'; +import { getEmojiName } from '@@/js/emojilist.js'; import MkTooltip from './MkTooltip.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; -import { getEmojiName } from '@/scripts/emojilist.js'; defineProps<{ showing: boolean; diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue index 26223364ab8ea5ff01f7742215133ead1d59afe5..f42a0b3227f396c6a46d9cae2abc1fc52e841977 100644 --- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue +++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue @@ -20,6 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, inject, onMounted, shallowRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; +import { getUnicodeEmoji } from '@@/js/emojilist.js'; import MkCustomEmojiDetailedDialog from './MkCustomEmojiDetailedDialog.vue'; import XDetails from '@/components/MkReactionsViewer.details.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; @@ -34,7 +35,6 @@ import { i18n } from '@/i18n.js'; import * as sound from '@/scripts/sound.js'; import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.js'; import { customEmojisMap } from '@/custom-emojis.js'; -import { getUnicodeEmoji } from '@/scripts/emojilist.js'; const props = defineProps<{ reaction: string; diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue index 0eba8d6a9cb6553b7510d0860e02237649f15fee..360d697d7cd5b58e84ed7a883110e3c9b3f4bbfb 100644 --- a/packages/frontend/src/components/MkSelect.vue +++ b/packages/frontend/src/components/MkSelect.vue @@ -44,7 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { onMounted, nextTick, ref, watch, computed, toRefs, VNode, useSlots, VNodeChild } from 'vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; -import { useInterval } from '@/scripts/use-interval.js'; +import { useInterval } from '@@/js/use-interval.js'; import { i18n } from '@/i18n.js'; import { MenuItem } from '@/types/menu.js'; diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue index 781145e1bc4268da2c6c36b99a1bf0d1113ded63..dabbe974683778ec4934e1dc7d5b085a521b148b 100644 --- a/packages/frontend/src/components/MkSignin.vue +++ b/packages/frontend/src/components/MkSignin.vue @@ -66,6 +66,7 @@ import { defineAsyncComponent, ref } from 'vue'; import { toUnicode } from 'punycode/'; import * as Misskey from 'misskey-js'; import { supported as webAuthnSupported, get as webAuthnRequest, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill'; +import { query, extractDomain } from '@@/js/url.js'; import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js'; import MkButton from '@/components/MkButton.vue'; @@ -74,7 +75,6 @@ import MkInfo from '@/components/MkInfo.vue'; import { host as configHost } from '@/config.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; -import { query, extractDomain } from '@/scripts/url.js'; import { login } from '@/account.js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue index ee224dba495cccea96996f7098b3d60eb9d0e6fc..35c07bc80ca3ae7505af3121c38eac64a9bdf3c5 100644 --- a/packages/frontend/src/components/global/MkAvatar.vue +++ b/packages/frontend/src/components/global/MkAvatar.vue @@ -42,10 +42,10 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { watch, ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; +import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js'; import MkImgWithBlurhash from '../MkImgWithBlurhash.vue'; import MkA from './MkA.vue'; import { getStaticImageUrl } from '@/scripts/media-proxy.js'; -import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash.js'; import { acct, userPage } from '@/filters/user.js'; import MkUserOnlineIndicator from '@/components/MkUserOnlineIndicator.vue'; import { defaultStore } from '@/store.js'; diff --git a/packages/frontend/src/components/global/MkEmoji.vue b/packages/frontend/src/components/global/MkEmoji.vue index fa780d4ad369ac8fb6d0f080a4658b7e8040359e..fc3745c0090d4330b6bcb21d145aba17c11dee51 100644 --- a/packages/frontend/src/components/global/MkEmoji.vue +++ b/packages/frontend/src/components/global/MkEmoji.vue @@ -10,9 +10,9 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, inject } from 'vue'; -import { char2fluentEmojiFilePath, char2twemojiFilePath } from '@/scripts/emoji-base.js'; +import { colorizeEmoji, getEmojiName } from '@@/js/emojilist.js'; +import { char2fluentEmojiFilePath, char2twemojiFilePath } from '@@/js/emoji-base.js'; import { defaultStore } from '@/store.js'; -import { colorizeEmoji, getEmojiName } from '@/scripts/emojilist.js'; import * as os from '@/os.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import * as sound from '@/scripts/sound.js'; diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts index 0d869892bdd288fb3bfc37e0d5eea62b77798bad..ea1f3e2988f128cfd2b759435b95cc2ad83d4f9e 100644 --- a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts +++ b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts @@ -19,8 +19,13 @@ import MkSparkle from '@/components/MkSparkle.vue'; import MkA, { MkABehavior } from '@/components/global/MkA.vue'; import { host } from '@/config.js'; import { defaultStore } from '@/store.js'; -import { nyaize as doNyaize } from '@/scripts/nyaize.js'; -import { safeParseFloat } from '@/scripts/safe-parse.js'; + +function safeParseFloat(str: unknown): number | null { + if (typeof str !== 'string' || str === '') return null; + const num = parseFloat(str); + if (isNaN(num)) return null; + return num; +} const QUOTE_STYLE = ` display: block; @@ -86,7 +91,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven case 'text': { let text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n'); if (!disableNyaize && shouldNyaize) { - text = doNyaize(text); + text = Misskey.nyaize(text); } if (!props.plain) { @@ -281,14 +286,14 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven const child = token.children[0]; let text = child.type === 'text' ? child.props.text : ''; if (!disableNyaize && shouldNyaize) { - text = doNyaize(text); + text = Misskey.nyaize(text); } return h('ruby', {}, [text.split(' ')[0], h('rt', text.split(' ')[1])]); } else { const rt = token.children.at(-1)!; let text = rt.type === 'text' ? rt.props.text : ''; if (!disableNyaize && shouldNyaize) { - text = doNyaize(text); + text = Misskey.nyaize(text); } return h('ruby', {}, [...genEl(token.children.slice(0, token.children.length - 1), scale), h('rt', text.trim())]); } @@ -400,7 +405,6 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven } case 'emojiCode': { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (props.author?.host == null) { return [h(MkCustomEmoji, { key: Math.random(), diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue index f16d951679c37a3833fb8224e9316fc56405a790..f1a451808f443fbf6b482bbba8c7863c22ff8b95 100644 --- a/packages/frontend/src/components/global/MkPageHeader.vue +++ b/packages/frontend/src/components/global/MkPageHeader.vue @@ -44,7 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { onMounted, onUnmounted, ref, inject, shallowRef, computed } from 'vue'; import tinycolor from 'tinycolor2'; import XTabs, { Tab } from './MkPageHeader.tabs.vue'; -import { scrollToTop } from '@/scripts/scroll.js'; +import { scrollToTop } from '@@/js/scroll.js'; import { globalEvents } from '@/events.js'; import { injectReactiveMetadata } from '@/scripts/page-metadata.js'; import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js'; diff --git a/packages/frontend/src/components/global/MkStickyContainer.vue b/packages/frontend/src/components/global/MkStickyContainer.vue index b12dc8cb31ad864b5249a694a5baf1bf00e6d0b9..3f37354908111ff4e4317194fd3bceb17be6f00a 100644 --- a/packages/frontend/src/components/global/MkStickyContainer.vue +++ b/packages/frontend/src/components/global/MkStickyContainer.vue @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onMounted, onUnmounted, provide, inject, Ref, ref, watch, shallowRef } from 'vue'; -import { CURRENT_STICKY_BOTTOM, CURRENT_STICKY_TOP } from '@/const.js'; +import { CURRENT_STICKY_BOTTOM, CURRENT_STICKY_TOP } from '@@/js/const.js'; const rootEl = shallowRef<HTMLElement>(); const headerEl = shallowRef<HTMLElement>(); diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue index d2ddd4aa85225e3dc2976bfb0b0c516ea329654e..8f4e3b853a3ad61d51b91f29b725354d2fe15ab2 100644 --- a/packages/frontend/src/components/global/MkUrl.vue +++ b/packages/frontend/src/components/global/MkUrl.vue @@ -30,10 +30,17 @@ import { toUnicode as decodePunycode } from 'punycode/'; import { url as local } from '@/config.js'; import * as os from '@/os.js'; import { useTooltip } from '@/scripts/use-tooltip.js'; -import { safeURIDecode } from '@/scripts/safe-uri-decode.js'; import { isEnabledUrlPreview } from '@/instance.js'; import { MkABehavior } from '@/components/global/MkA.vue'; +function safeURIDecode(str: string): string { + try { + return decodeURIComponent(str); + } catch { + return str; + } +} + const props = withDefaults(defineProps<{ url: string; rel?: string; diff --git a/packages/frontend/src/custom-emojis.ts b/packages/frontend/src/custom-emojis.ts index 9da3582e1a616dc4fbb34b197a82a9fed2658007..0d03282cee22c8f4fced75f2d4eb5eb756bbe7a7 100644 --- a/packages/frontend/src/custom-emojis.ts +++ b/packages/frontend/src/custom-emojis.ts @@ -6,7 +6,6 @@ import { shallowRef, computed, markRaw, watch } from 'vue'; import * as Misskey from 'misskey-js'; import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; -import { useStream } from '@/stream.js'; import { get, set } from '@/scripts/idb-proxy.js'; const storageCache = await get('emojis'); @@ -29,23 +28,20 @@ watch(customEmojis, emojis => { } }, { immediate: true }); -// TODO: ã“ã“ら辺副作用ãªã®ã§ã„ã„æ„Ÿã˜ã«ã™ã‚‹ -const stream = useStream(); - -stream.on('emojiAdded', emojiData => { - customEmojis.value = [emojiData.emoji, ...customEmojis.value]; +export function addCustomEmoji(emoji: Misskey.entities.EmojiSimple) { + customEmojis.value = [emoji, ...customEmojis.value]; set('emojis', customEmojis.value); -}); +} -stream.on('emojiUpdated', emojiData => { - customEmojis.value = customEmojis.value.map(item => emojiData.emojis.find(search => search.name === item.name) as Misskey.entities.EmojiSimple ?? item); +export function updateCustomEmojis(emojis: Misskey.entities.EmojiSimple[]) { + customEmojis.value = customEmojis.value.map(item => emojis.find(search => search.name === item.name) ?? item); set('emojis', customEmojis.value); -}); +} -stream.on('emojiDeleted', emojiData => { - customEmojis.value = customEmojis.value.filter(item => !emojiData.emojis.some(search => search.name === item.name)); +export function removeCustomEmojis(emojis: Misskey.entities.EmojiSimple[]) { + customEmojis.value = customEmojis.value.filter(item => !emojis.some(search => search.name === item.name)); set('emojis', customEmojis.value); -}); +} export async function fetchCustomEmojis(force = false) { const now = Date.now(); diff --git a/packages/frontend/src/directives/follow-append.ts b/packages/frontend/src/directives/follow-append.ts index f200f242ed23621d44a5d91a697b4039269762b0..615dd99fa8d4d384520b81eb78efc03762d3e293 100644 --- a/packages/frontend/src/directives/follow-append.ts +++ b/packages/frontend/src/directives/follow-append.ts @@ -4,7 +4,7 @@ */ import { Directive } from 'vue'; -import { getScrollContainer, getScrollPosition } from '@/scripts/scroll.js'; +import { getScrollContainer, getScrollPosition } from '@@/js/scroll.js'; export default { mounted(src, binding, vn) { diff --git a/packages/frontend/src/i18n.ts b/packages/frontend/src/i18n.ts index 10d6adbcd0ca9d203c748505aa108a8fc401ccc9..17e787f9fc17df7e6d481466686b5be930cead0a 100644 --- a/packages/frontend/src/i18n.ts +++ b/packages/frontend/src/i18n.ts @@ -4,11 +4,11 @@ */ import { markRaw } from 'vue'; +import { I18n } from '@@/js/i18n.js'; import type { Locale } from '../../../locales/index.js'; import { locale } from '@/config.js'; -import { I18n } from '@/scripts/i18n.js'; -export const i18n = markRaw(new I18n<Locale>(locale)); +export const i18n = markRaw(new I18n<Locale>(locale, _DEV_)); export function updateI18n(newLocale: Locale) { i18n.locale = newLocale; diff --git a/packages/frontend/src/instance.ts b/packages/frontend/src/instance.ts index 6847321d6c40d992191eef107ba740e2cf905998..71cb42b30c45f0840f5594ab97c43217321b51e5 100644 --- a/packages/frontend/src/instance.ts +++ b/packages/frontend/src/instance.ts @@ -7,7 +7,7 @@ import { computed, reactive } from 'vue'; import * as Misskey from 'misskey-js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { miLocalStorage } from '@/local-storage.js'; -import { DEFAULT_INFO_IMAGE_URL, DEFAULT_NOT_FOUND_IMAGE_URL, DEFAULT_SERVER_ERROR_IMAGE_URL } from '@/const.js'; +import { DEFAULT_INFO_IMAGE_URL, DEFAULT_NOT_FOUND_IMAGE_URL, DEFAULT_SERVER_ERROR_IMAGE_URL } from '@@/js/const.js'; // TODO: ä»–ã®ã‚¿ãƒ–ã¨æ°¸ç¶šåŒ–ã•ã‚ŒãŸstateã‚’åŒæœŸ diff --git a/packages/frontend/src/local-storage.ts b/packages/frontend/src/local-storage.ts index 8029bca68d53030af27b2f190a59c9de43bcca9a..5b8ba77e012169076844ab60d5c95aa63db3e7a0 100644 --- a/packages/frontend/src/local-storage.ts +++ b/packages/frontend/src/local-storage.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -type Keys = +export type Keys = 'v' | 'lastVersion' | 'instance' | @@ -38,12 +38,22 @@ type Keys = `aiscript:${string}` | 'lastEmojisFetchedAt' | // DEPRECATED, stored in indexeddb (13.9.0~) 'emojis' | // DEPRECATED, stored in indexeddb (13.9.0~); - `channelLastReadedAt:${string}` + `channelLastReadedAt:${string}` | + `idbfallback::${string}` + +// セッション毎ã«å»ƒæ£„ã•ã‚Œã‚‹LocalStorage代替(セーフモードãªã©ã§ä½¿ç”¨ã§ããã†ï¼‰ +//const safeSessionStorage = new Map<Keys, string>(); export const miLocalStorage = { - getItem: (key: Keys): string | null => window.localStorage.getItem(key), - setItem: (key: Keys, value: string): void => window.localStorage.setItem(key, value), - removeItem: (key: Keys): void => window.localStorage.removeItem(key), + getItem: (key: Keys): string | null => { + return window.localStorage.getItem(key); + }, + setItem: (key: Keys, value: string): void => { + window.localStorage.setItem(key, value); + }, + removeItem: (key: Keys): void => { + window.localStorage.removeItem(key); + }, getItemAsJson: (key: Keys): any | undefined => { const item = miLocalStorage.getItem(key); if (item === null) { @@ -51,5 +61,7 @@ export const miLocalStorage = { } return JSON.parse(item); }, - setItemAsJson: (key: Keys, value: any): void => window.localStorage.setItem(key, JSON.stringify(value)), + setItemAsJson: (key: Keys, value: any): void => { + miLocalStorage.setItem(key, JSON.stringify(value)); + }, }; diff --git a/packages/frontend/src/nirax.ts b/packages/frontend/src/nirax.ts index 6a8ea09ed6af93d1bbef30fbfbec04f88f41c48b..25f853453a0dd67fbaec57742d325c453b9e9d8f 100644 --- a/packages/frontend/src/nirax.ts +++ b/packages/frontend/src/nirax.ts @@ -7,7 +7,14 @@ import { Component, onMounted, shallowRef, ShallowRef } from 'vue'; import { EventEmitter } from 'eventemitter3'; -import { safeURIDecode } from '@/scripts/safe-uri-decode.js'; + +function safeURIDecode(str: string): string { + try { + return decodeURIComponent(str); + } catch { + return str; + } +} interface RouteDefBase { path: string; diff --git a/packages/frontend/src/pages/admin/_header_.vue b/packages/frontend/src/pages/admin/_header_.vue index b88f078598ed33c1d5f8ed75a922fe5643ace416..d22e078c2a82fe786127e8627c4adfe4bf7ddf1f 100644 --- a/packages/frontend/src/pages/admin/_header_.vue +++ b/packages/frontend/src/pages/admin/_header_.vue @@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, onMounted, onUnmounted, ref, shallowRef, watch, nextTick } from 'vue'; import tinycolor from 'tinycolor2'; import { popupMenu } from '@/os.js'; -import { scrollToTop } from '@/scripts/scroll.js'; +import { scrollToTop } from '@@/js/scroll.js'; import MkButton from '@/components/MkButton.vue'; import { globalEvents } from '@/events.js'; import { injectReactiveMetadata } from '@/scripts/page-metadata.js'; diff --git a/packages/frontend/src/pages/admin/overview.instances.vue b/packages/frontend/src/pages/admin/overview.instances.vue index a09db2a6d52805ca1e54a26a4ae9b16850f401cd..292e2e1dbc9f94f098eac66e5214508348bc357e 100644 --- a/packages/frontend/src/pages/admin/overview.instances.vue +++ b/packages/frontend/src/pages/admin/overview.instances.vue @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import { misskeyApi } from '@/scripts/misskey-api.js'; import * as Misskey from 'misskey-js'; -import { useInterval } from '@/scripts/use-interval.js'; +import { useInterval } from '@@/js/use-interval.js'; import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue'; import { defaultStore } from '@/store.js'; diff --git a/packages/frontend/src/pages/admin/overview.users.vue b/packages/frontend/src/pages/admin/overview.users.vue index a7dd4c0a485fdaa582f76f83a8d92e41bd5ad253..8c9d7a8197a23a6e749959b9aef34326c04e4d46 100644 --- a/packages/frontend/src/pages/admin/overview.users.vue +++ b/packages/frontend/src/pages/admin/overview.users.vue @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import { misskeyApi } from '@/scripts/misskey-api.js'; import * as Misskey from 'misskey-js'; -import { useInterval } from '@/scripts/use-interval.js'; +import { useInterval } from '@@/js/use-interval.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; import { defaultStore } from '@/store.js'; diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index 3e948abdf13d2484bbc405af7e7ae9d67dcf3f5d..b0137abb3f86462fb9e21aeedd07414a05626ddf 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -608,7 +608,7 @@ import MkSwitch from '@/components/MkSwitch.vue'; import MkRange from '@/components/MkRange.vue'; import FormSlot from '@/components/form/slot.vue'; import { i18n } from '@/i18n.js'; -import { ROLE_POLICIES } from '@/const.js'; +import { ROLE_POLICIES } from '@@/js/const.js'; import { instance } from '@/instance.js'; import { deepClone } from '@/scripts/clone.js'; diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue index 6fb950494b2ea9bbcee322b870f6e671888815ca..7e29f6e0d8bbc155e083612f0024518390a064d6 100644 --- a/packages/frontend/src/pages/admin/roles.vue +++ b/packages/frontend/src/pages/admin/roles.vue @@ -253,7 +253,7 @@ import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { instance, fetchInstance } from '@/instance.js'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; -import { ROLE_POLICIES } from '@/const.js'; +import { ROLE_POLICIES } from '@@/js/const.js'; import { useRouter } from '@/router/supplier.js'; const router = useRouter(); diff --git a/packages/frontend/src/pages/antenna-timeline.vue b/packages/frontend/src/pages/antenna-timeline.vue index ea64e457e376f77ae4ef2148b811bdfa60449e62..22c5231dd94f48a02116bbbdcf03ca6e116a525e 100644 --- a/packages/frontend/src/pages/antenna-timeline.vue +++ b/packages/frontend/src/pages/antenna-timeline.vue @@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, watch, ref, shallowRef } from 'vue'; import * as Misskey from 'misskey-js'; import MkTimeline from '@/components/MkTimeline.vue'; -import { scroll } from '@/scripts/scroll.js'; +import { scroll } from '@@/js/scroll.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue index fb984de368a6690d05ba6e055199c69ab8ab6b93..aad6acb4b5055b4af74a2204d53d17ff043c90cb 100644 --- a/packages/frontend/src/pages/clip.vue +++ b/packages/frontend/src/pages/clip.vue @@ -44,6 +44,7 @@ import MkButton from '@/components/MkButton.vue'; import { clipsCache } from '@/cache.js'; import { isSupportShare } from '@/scripts/navigator.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { genEmbedCode } from '@/scripts/get-embed-code.js'; const props = defineProps<{ clipId: string, @@ -127,21 +128,33 @@ const headerActions = computed(() => clip.value && isOwned.value ? [{ clipsCache.delete(); }, }, ...(clip.value.isPublic ? [{ - icon: 'ti ti-link', - text: i18n.ts.copyUrl, - handler: async (): Promise<void> => { - copyToClipboard(`${url}/clips/${clip.value.id}`); - os.success(); - }, -}] : []), ...(clip.value.isPublic && isSupportShare() ? [{ icon: 'ti ti-share', text: i18n.ts.share, - handler: async (): Promise<void> => { - navigator.share({ - title: clip.value.name, - text: clip.value.description, - url: `${url}/clips/${clip.value.id}`, - }); + handler: (ev: MouseEvent): void => { + os.popupMenu([{ + icon: 'ti ti-link', + text: i18n.ts.copyUrl, + action: () => { + copyToClipboard(`${url}/clips/${clip.value!.id}`); + os.success(); + }, + }, { + icon: 'ti ti-code', + text: i18n.ts.genEmbedCode, + action: () => { + genEmbedCode('clips', clip.value!.id); + }, + }, ...(isSupportShare() ? [{ + icon: 'ti ti-share', + text: i18n.ts.share, + action: async () => { + navigator.share({ + title: clip.value!.name, + text: clip.value!.description ?? '', + url: `${url}/clips/${clip.value!.id}`, + }); + }, + }] : [])], ev.currentTarget ?? ev.target); }, }] : []), { icon: 'ti ti-trash', diff --git a/packages/frontend/src/pages/drive.file.info.vue b/packages/frontend/src/pages/drive.file.info.vue index 3026d00a2cde106ede0a0b370c2ac58b0cde3f0b..ffedaf27bfecbbb293572eb68b5c7cdfebad54e9 100644 --- a/packages/frontend/src/pages/drive.file.info.vue +++ b/packages/frontend/src/pages/drive.file.info.vue @@ -99,12 +99,12 @@ const file = ref<Misskey.entities.DriveFile>(); const folderHierarchy = computed(() => { if (!file.value) return [i18n.ts.drive]; const folderNames = [i18n.ts.drive]; - + function get(folder: Misskey.entities.DriveFolder) { if (folder.parent) get(folder.parent); folderNames.push(folder.name); } - + if (file.value.folder) get(file.value.folder); return folderNames; }); diff --git a/packages/frontend/src/pages/drop-and-fusion.game.vue b/packages/frontend/src/pages/drop-and-fusion.game.vue index 0f0b7e1ea8e67eba6767f643648f9e586c22c571..b5e49021264896cc9fdeb7b50e33a00514736ca2 100644 --- a/packages/frontend/src/pages/drop-and-fusion.game.vue +++ b/packages/frontend/src/pages/drop-and-fusion.game.vue @@ -205,7 +205,7 @@ import { claimAchievement } from '@/scripts/achievements.js'; import { defaultStore } from '@/store.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { useInterval } from '@/scripts/use-interval.js'; +import { useInterval } from '@@/js/use-interval.js'; import { apiUrl } from '@/config.js'; import { $i } from '@/account.js'; import * as sound from '@/scripts/sound.js'; diff --git a/packages/frontend/src/pages/notifications.vue b/packages/frontend/src/pages/notifications.vue index 28f583829653684e0053c8757ded31b24652dba4..bd93fc8369659ef96d04c9ee915e24a6a7a84302 100644 --- a/packages/frontend/src/pages/notifications.vue +++ b/packages/frontend/src/pages/notifications.vue @@ -30,7 +30,7 @@ import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { notificationTypes } from '@/const.js'; +import { notificationTypes } from '@@/js/const.js'; const tab = ref('all'); const includeTypes = ref<string[] | null>(null); diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue index 7d9cefa5c9f44e7d2b91806967ac4289c0b910f3..578fd65ba1a021df977dfcbfc94332ec8794e9ea 100644 --- a/packages/frontend/src/pages/reversi/game.board.vue +++ b/packages/frontend/src/pages/reversi/game.board.vue @@ -149,7 +149,7 @@ import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import { deepClone } from '@/scripts/clone.js'; -import { useInterval } from '@/scripts/use-interval.js'; +import { useInterval } from '@@/js/use-interval.js'; import { signinRequired } from '@/account.js'; import { url } from '@/config.js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/pages/reversi/game.vue b/packages/frontend/src/pages/reversi/game.vue index 97a793753d8b18598e30513b90798fd6ecd29535..a25595e8848dff40374839424820023ed5417774 100644 --- a/packages/frontend/src/pages/reversi/game.vue +++ b/packages/frontend/src/pages/reversi/game.vue @@ -22,7 +22,7 @@ import { useRouter } from '@/router/supplier.js'; import * as os from '@/os.js'; import { url } from '@/config.js'; import { i18n } from '@/i18n.js'; -import { useInterval } from '@/scripts/use-interval.js'; +import { useInterval } from '@@/js/use-interval.js'; const $i = signinRequired(); diff --git a/packages/frontend/src/pages/reversi/index.vue b/packages/frontend/src/pages/reversi/index.vue index 51a03e441801df034807441aca575e3780ed9432..d823861b4ac634e73c3f5fa41cc69aafb8afb7d4 100644 --- a/packages/frontend/src/pages/reversi/index.vue +++ b/packages/frontend/src/pages/reversi/index.vue @@ -117,7 +117,7 @@ import { $i } from '@/account.js'; import MkPagination from '@/components/MkPagination.vue'; import { useRouter } from '@/router/supplier.js'; import * as os from '@/os.js'; -import { useInterval } from '@/scripts/use-interval.js'; +import { useInterval } from '@@/js/use-interval.js'; import { pleaseLogin } from '@/scripts/please-login.js'; import * as sound from '@/scripts/sound.js'; diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue index 70db6a5109649cb4d569e65b4e0414ab36df3954..cce671a7cb026c9095cbf824e5bfd32bed84fd04 100644 --- a/packages/frontend/src/pages/settings/notifications.vue +++ b/packages/frontend/src/pages/settings/notifications.vue @@ -69,7 +69,7 @@ import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue'; -import { notificationTypes } from '@/const.js'; +import { notificationTypes } from '@@/js/const.js'; const $i = signinRequired(); diff --git a/packages/frontend/src/pages/tag.vue b/packages/frontend/src/pages/tag.vue index 9b77392872fbfee9ee137ee83002ae335cba9c74..1b3e1ecaee67752060cf0ed14ac5e6852c2d481c 100644 --- a/packages/frontend/src/pages/tag.vue +++ b/packages/frontend/src/pages/tag.vue @@ -28,6 +28,7 @@ import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; import { defaultStore } from '@/store.js'; import * as os from '@/os.js'; +import { genEmbedCode } from '@/scripts/get-embed-code.js'; const props = defineProps<{ tag: string; @@ -51,7 +52,19 @@ async function post() { notes.value?.pagingComponent?.reload(); } -const headerActions = computed(() => []); +const headerActions = computed(() => [{ + icon: 'ti ti-dots', + label: i18n.ts.more, + handler: (ev: MouseEvent) => { + os.popupMenu([{ + text: i18n.ts.genEmbedCode, + icon: 'ti ti-code', + action: () => { + genEmbedCode('tags', props.tag); + }, + }], ev.currentTarget ?? ev.target); + } +}]); const headerTabs = computed(() => []); diff --git a/packages/frontend/src/pages/theme-editor.vue b/packages/frontend/src/pages/theme-editor.vue index 50c3beeabc3bda98bcef06013fbde6eb67721132..fe7896b7d955b9d40a23209c54db7930c152e455 100644 --- a/packages/frontend/src/pages/theme-editor.vue +++ b/packages/frontend/src/pages/theme-editor.vue @@ -79,6 +79,8 @@ import tinycolor from 'tinycolor2'; import { v4 as uuid } from 'uuid'; import JSON5 from 'json5'; +import lightTheme from '@@/themes/_light.json5'; +import darkTheme from '@@/themes/_dark.json5'; import MkButton from '@/components/MkButton.vue'; import MkCodeEditor from '@/components/MkCodeEditor.vue'; import MkTextarea from '@/components/MkTextarea.vue'; @@ -86,8 +88,6 @@ import MkFolder from '@/components/MkFolder.vue'; import { $i } from '@/account.js'; import { Theme, applyTheme } from '@/scripts/theme.js'; -import lightTheme from '@/themes/_light.json5'; -import darkTheme from '@/themes/_dark.json5'; import { host } from '@/config.js'; import * as os from '@/os.js'; import { ColdDeviceStorage, defaultStore } from '@/store.js'; diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index d5943e8fbc616f9f1791ac6d605718ec649720bb..cc1ed3d01f29de94d268457a42aa8264571498c3 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -40,7 +40,7 @@ import MkTimeline from '@/components/MkTimeline.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkPostForm from '@/components/MkPostForm.vue'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; -import { scroll } from '@/scripts/scroll.js'; +import { scroll } from '@@/js/scroll.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore } from '@/store.js'; @@ -54,7 +54,7 @@ import { MenuItem } from '@/types/menu.js'; import { miLocalStorage } from '@/local-storage.js'; import { availableBasicTimelines, hasWithReplies, isAvailableBasicTimeline, isBasicTimeline, basicTimelineIconClass } from '@/timelines.js'; import type { BasicTimelineType } from '@/timelines.js'; - + provide('shouldOmitHeaderTitle', true); const tlComponent = shallowRef<InstanceType<typeof MkTimeline>>(); diff --git a/packages/frontend/src/pages/user-list-timeline.vue b/packages/frontend/src/pages/user-list-timeline.vue index de6737f37d82ca8c00d8b034c07839b79a84b618..31a3f1b0607b306236f66c2d276281617d81f5ae 100644 --- a/packages/frontend/src/pages/user-list-timeline.vue +++ b/packages/frontend/src/pages/user-list-timeline.vue @@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, watch, ref, shallowRef } from 'vue'; import * as Misskey from 'misskey-js'; import MkTimeline from '@/components/MkTimeline.vue'; -import { scroll } from '@/scripts/scroll.js'; +import { scroll } from '@@/js/scroll.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 3039ec749987b20843a8dca2529c636d0d68f553..8e0292c7fe3835756dd345827239d5f9fd9e3622 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -161,7 +161,7 @@ import MkTextarea from '@/components/MkTextarea.vue'; import MkOmit from '@/components/MkOmit.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkButton from '@/components/MkButton.vue'; -import { getScrollPosition } from '@/scripts/scroll.js'; +import { getScrollPosition } from '@@/js/scroll.js'; import { getUserMenu } from '@/scripts/get-user-menu.js'; import number from '@/filters/number.js'; import { userPage } from '@/filters/user.js'; diff --git a/packages/frontend/src/pages/welcome.timeline.vue b/packages/frontend/src/pages/welcome.timeline.vue index db326f9e6cf489252708c0f8380547c5160f01ae..732d483615bf10edb08e18a8c5e4453066d1e84d 100644 --- a/packages/frontend/src/pages/welcome.timeline.vue +++ b/packages/frontend/src/pages/welcome.timeline.vue @@ -24,7 +24,7 @@ import * as Misskey from 'misskey-js'; import { onUpdated, ref, shallowRef } from 'vue'; import XNote from '@/pages/welcome.timeline.note.vue'; import { misskeyApiGet } from '@/scripts/misskey-api.js'; -import { getScrollContainer } from '@/scripts/scroll.js'; +import { getScrollContainer } from '@@/js/scroll.js'; const notes = ref<Misskey.entities.Note[]>([]); const isScrolling = ref(false); diff --git a/packages/frontend/src/router/definition.ts b/packages/frontend/src/router/definition.ts index 995a2055b85090dbca6f04f86d633af6c0e24a04..8a29fd677ec9a11d2a356eb5d265f49810b60bfe 100644 --- a/packages/frontend/src/router/definition.ts +++ b/packages/frontend/src/router/definition.ts @@ -3,15 +3,14 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { App, AsyncComponentLoader, defineAsyncComponent, provide } from 'vue'; -import type { RouteDef } from '@/nirax.js'; -import { IRouter, Router } from '@/nirax.js'; +import { AsyncComponentLoader, defineAsyncComponent } from 'vue'; +import type { IRouter, RouteDef } from '@/nirax.js'; +import { Router } from '@/nirax.js'; import { $i, iAmModerator } from '@/account.js'; import MkLoading from '@/pages/_loading_.vue'; import MkError from '@/pages/_error_.vue'; -import { setMainRouter } from '@/router/main.js'; -const page = (loader: AsyncComponentLoader<any>) => defineAsyncComponent({ +export const page = (loader: AsyncComponentLoader<any>) => defineAsyncComponent({ loader: loader, loadingComponent: MkLoading, errorComponent: MkError, @@ -240,7 +239,7 @@ const routes: RouteDef[] = [{ origin: 'origin', }, }, { - // Legacy Compatibility + // Legacy Compatibility path: '/authorize-follow', redirect: '/lookup', loginRequired: true, @@ -597,32 +596,6 @@ const routes: RouteDef[] = [{ component: page(() => import('@/pages/not-found.vue')), }]; -function createRouterImpl(path: string): IRouter { +export function createMainRouter(path: string): IRouter { return new Router(routes, path, !!$i, page(() => import('@/pages/not-found.vue'))); } - -/** - * {@link Router}ã«ã‚ˆã‚‹ç”»é¢é·ç§»ã‚’å¯èƒ½ã¨ã™ã‚‹ãŸã‚ã«{@link mainRouter}をセットアップã™ã‚‹ã€‚ - * ã¾ãŸã€{@link Router}ã®ã‚¤ãƒ³ã‚¹ã‚¿ãƒ³ã‚¹ã‚’作æˆã™ã‚‹ãŸã‚ã®ãƒ•ã‚¡ã‚¯ãƒˆãƒªã‚‚{@link provide}経由ã§å…¬é–‹ã™ã‚‹ï¼ˆ`routerFactory`ã¨ã„ã†ã‚ーã§å–å¾—å¯èƒ½ï¼‰ - */ -export function setupRouter(app: App) { - app.provide('routerFactory', createRouterImpl); - - const mainRouter = createRouterImpl(location.pathname + location.search + location.hash); - - window.addEventListener('popstate', (event) => { - mainRouter.replace(location.pathname + location.search + location.hash, event.state?.key); - }); - - mainRouter.addListener('push', ctx => { - window.history.pushState({ key: ctx.key }, '', ctx.path); - }); - - mainRouter.addListener('replace', ctx => { - window.history.replaceState({ key: ctx.key }, '', ctx.path); - }); - - mainRouter.init(); - - setMainRouter(mainRouter); -} diff --git a/packages/frontend/src/router/main.ts b/packages/frontend/src/router/main.ts index 7a3fde131ed840139b459b4ad1148a7bb8e367d8..6ee967e6f443a2be54c6afca33d38835fab7811a 100644 --- a/packages/frontend/src/router/main.ts +++ b/packages/frontend/src/router/main.ts @@ -3,10 +3,37 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { ShallowRef } from 'vue'; import { EventEmitter } from 'eventemitter3'; import { IRouter, Resolved, RouteDef, RouterEvent } from '@/nirax.js'; +import type { App, ShallowRef } from 'vue'; + +/** + * {@link Router}ã«ã‚ˆã‚‹ç”»é¢é·ç§»ã‚’å¯èƒ½ã¨ã™ã‚‹ãŸã‚ã«{@link mainRouter}をセットアップã™ã‚‹ã€‚ + * ã¾ãŸã€{@link Router}ã®ã‚¤ãƒ³ã‚¹ã‚¿ãƒ³ã‚¹ã‚’作æˆã™ã‚‹ãŸã‚ã®ãƒ•ã‚¡ã‚¯ãƒˆãƒªã‚‚{@link provide}経由ã§å…¬é–‹ã™ã‚‹ï¼ˆ`routerFactory`ã¨ã„ã†ã‚ーã§å–å¾—å¯èƒ½ï¼‰ + */ +export function setupRouter(app: App, routerFactory: ((path: string) => IRouter)): void { + app.provide('routerFactory', routerFactory); + + const mainRouter = routerFactory(location.pathname + location.search + location.hash); + + window.addEventListener('popstate', (event) => { + mainRouter.replace(location.pathname + location.search + location.hash, event.state?.key); + }); + + mainRouter.addListener('push', ctx => { + window.history.pushState({ key: ctx.key }, '', ctx.path); + }); + + mainRouter.addListener('replace', ctx => { + window.history.replaceState({ key: ctx.key }, '', ctx.path); + }); + + mainRouter.init(); + + setMainRouter(mainRouter); +} + function getMainRouter(): IRouter { const router = mainRouterHolder; if (!router) { diff --git a/packages/frontend/src/scripts/aiscript/api.ts b/packages/frontend/src/scripts/aiscript/api.ts index 98a0c61752cb66c446dd5b552f18795e218e4901..417ba08c3fa4223d6038fe7d9bd84de3a6b380b5 100644 --- a/packages/frontend/src/scripts/aiscript/api.ts +++ b/packages/frontend/src/scripts/aiscript/api.ts @@ -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)); }), }; } diff --git a/packages/frontend/src/scripts/check-reaction-permissions.ts b/packages/frontend/src/scripts/check-reaction-permissions.ts index 8fc857f84fce06babd2417ad944cc96222e96322..c3c3f419a978c064f94107e1de0421813dba6add 100644 --- a/packages/frontend/src/scripts/check-reaction-permissions.ts +++ b/packages/frontend/src/scripts/check-reaction-permissions.ts @@ -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絵文å—ã§ã‚ã‚‹ã“ã¨ã«ã¯å¤‰ã‚ã‚Šãªã„ã®ã§å¸¸ã«ãƒªã‚¢ã‚¯ã‚·ãƒ§ãƒ³å¯èƒ½ã¨ã™ã‚‹; diff --git a/packages/frontend/src/scripts/code-highlighter.ts b/packages/frontend/src/scripts/code-highlighter.ts index e94027d3022a516e4e08d28b3a1c9743347b380b..b0ffac93d70e76e65888419af81dd460d8609c0c 100644 --- a/packages/frontend/src/scripts/code-highlighter.ts +++ b/packages/frontend/src/scripts/code-highlighter.ts @@ -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; diff --git a/packages/frontend/src/scripts/focus.ts b/packages/frontend/src/scripts/focus.ts index eb2da5ad8665c47ebe6c7d55b7c8f9684a79c407..81278b17ea6de36820ae97f5ae45412fc2ab556a 100644 --- a/packages/frontend/src/scripts/focus.ts +++ b/packages/frontend/src/scripts/focus.ts @@ -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; diff --git a/packages/frontend/src/scripts/get-embed-code.ts b/packages/frontend/src/scripts/get-embed-code.ts new file mode 100644 index 0000000000000000000000000000000000000000..007cd6561b5c998cde914f9c745e5d957b780405 --- /dev/null +++ b/packages/frontend/src/scripts/get-embed-code.ts @@ -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(), + }); + } +} diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts index b5d7350a41b6b137702fcfd667fa34572930a681..e0ccea813dc3a37f294f6edc5ebfeee6e2e72356 100644 --- a/packages/frontend/src/scripts/get-note-menu.ts +++ b/packages/frontend/src/scripts/get-note-menu.ts @@ -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); } diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts index 33f16a68aa618d4e8c6c8ae9b3b03b0324d1b20c..035abc7bd061f642d058d0f77abc811bffe53642 100644 --- a/packages/frontend/src/scripts/get-user-menu.ts +++ b/packages/frontend/src/scripts/get-user-menu.ts @@ -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: () => { diff --git a/packages/frontend/src/scripts/idb-proxy.ts b/packages/frontend/src/scripts/idb-proxy.ts index 6b511f2a5fc78ed8be89a74a393014984746f16f..20f51660c725ddc15a79b50d80a675ea0559477e 100644 --- a/packages/frontend/src/scripts/idb-proxy.ts +++ b/packages/frontend/src/scripts/idb-proxy.ts @@ -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}`); } diff --git a/packages/frontend/src/scripts/is-link.ts b/packages/frontend/src/scripts/is-link.ts new file mode 100644 index 0000000000000000000000000000000000000000..946f86400e16e75b4f4e73b460b98006363be0ec --- /dev/null +++ b/packages/frontend/src/scripts/is-link.ts @@ -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; +} diff --git a/packages/frontend/src/scripts/media-proxy.ts b/packages/frontend/src/scripts/media-proxy.ts index 099a22163af44a0e8c2e6c3444bda13e2706fada..68a5a1dcf886eaeee058c6336c28febe91b4aa34 100644 --- a/packages/frontend/src/scripts/media-proxy.ts +++ b/packages/frontend/src/scripts/media-proxy.ts @@ -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); } diff --git a/packages/frontend/src/scripts/mfm-function-picker.ts b/packages/frontend/src/scripts/mfm-function-picker.ts index 9938e534c139e72d0c57c748fbf3f10811158bdf..bf59fe98a0edb14cbe600df84d1952237e029820 100644 --- a/packages/frontend/src/scripts/mfm-function-picker.ts +++ b/packages/frontend/src/scripts/mfm-function-picker.ts @@ -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'; /** diff --git a/packages/frontend/src/scripts/popout.ts b/packages/frontend/src/scripts/popout.ts index 1caa2dfc21017074e4233f2176e312e94d6a5f3a..ed49611b4f3798cd9723f146f17324bfcc4e9091 100644 --- a/packages/frontend/src/scripts/popout.ts +++ b/packages/frontend/src/scripts/popout.ts @@ -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) { diff --git a/packages/frontend/src/scripts/post-message.ts b/packages/frontend/src/scripts/post-message.ts index 31a9ac1ad9dca54f33892827884254d48bee010b..11b6f52ddd0db4fb7d6f4e0589422ed6babd27a8 100644 --- a/packages/frontend/src/scripts/post-message.ts +++ b/packages/frontend/src/scripts/post-message.ts @@ -18,7 +18,7 @@ export type MiPostMessageEvent = { * 親フレームã«ã‚¤ãƒ™ãƒ³ãƒˆã‚’é€ä¿¡ */ export function postMessageToParentWindow(type: PostMessageEventType, payload?: any): void { - window.postMessage({ + window.parent.postMessage({ type, payload, }, '*'); diff --git a/packages/frontend/src/scripts/safe-parse.ts b/packages/frontend/src/scripts/safe-parse.ts deleted file mode 100644 index 6bfcef6c362c208d4b3dbc4cbb3eb8a84e9c73d7..0000000000000000000000000000000000000000 --- a/packages/frontend/src/scripts/safe-parse.ts +++ /dev/null @@ -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; -} diff --git a/packages/frontend/src/scripts/safe-uri-decode.ts b/packages/frontend/src/scripts/safe-uri-decode.ts deleted file mode 100644 index 0edf4e9eba0f32f6bded54812f4a71afb74a1379..0000000000000000000000000000000000000000 --- a/packages/frontend/src/scripts/safe-uri-decode.ts +++ /dev/null @@ -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; - } -} diff --git a/packages/frontend/src/scripts/stream-mock.ts b/packages/frontend/src/scripts/stream-mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb0e607fcb6ad6a57e10a735ea8259696ef5ae9e --- /dev/null +++ b/packages/frontend/src/scripts/stream-mock.ts @@ -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 + } +} diff --git a/packages/frontend/src/scripts/theme.ts b/packages/frontend/src/scripts/theme.ts index c7f8b3d59663c518e2a03f626d1f916531773f73..9b9f1f030c330d0027f07a4948d115191e1b63a6 100644 --- a/packages/frontend/src/scripts/theme.ts +++ b/packages/frontend/src/scripts/theme.ts @@ -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 = { diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 437314074a0c5a966e81a70574a61c685189c8ad..0bf499bb4d605d2a6fa47e7bb46a92bb68e063d0 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -458,10 +458,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: false, }, - contextMenu: { + contextMenu: { where: 'device', default: 'app' as 'app' | 'appWithShift' | 'native', - }, + }, sound_masterVolume: { where: 'device', @@ -520,8 +520,8 @@ interface Watcher { /** * 常ã«ãƒ¡ãƒ¢ãƒªã«ãƒãƒ¼ãƒ‰ã—ã¦ãŠãå¿…è¦ãŒãªã„よã†ãªè¨å®šæƒ…å ±ã‚’ä¿ç®¡ã™ã‚‹ã‚¹ãƒˆãƒ¬ãƒ¼ã‚¸(éžãƒªã‚¢ã‚¯ãƒ†ã‚£ãƒ–) */ -import lightTheme from '@/themes/l-light.json5'; -import darkTheme from '@/themes/d-green-lime.json5'; +import lightTheme from '@@/themes/l-light.json5'; +import darkTheme from '@@/themes/d-green-lime.json5'; export class ColdDeviceStorage { public static default = { @@ -558,7 +558,7 @@ export class ColdDeviceStorage { public static set<T extends keyof typeof ColdDeviceStorage.default>(key: T, value: typeof ColdDeviceStorage.default[T]): void { // 呼ã³å‡ºã—å´ã®ãƒã‚°ç‰ã§ undefined ãŒæ¥ã‚‹ã“ã¨ãŒã‚ã‚‹ // undefined ã‚’æ–‡å—列ã¨ã—㦠miLocalStorage ã«å…¥ã‚Œã‚‹ã¨å‚ç…§ã™ã‚‹éš›ã® JSON.parse ã§ã‚³ã‚±ã¦ä¸å…·åˆã®å…ƒã«ãªã‚‹ãŸã‚無視 - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (value === undefined) { console.error(`attempt to store undefined value for key '${key}'`); return; diff --git a/packages/frontend/src/stream.ts b/packages/frontend/src/stream.ts index 0d5bd78b09af845f20d1a99c3803833f9013c711..9d7edce890f6cb29925f7f7767f50e5a35b0288f 100644 --- a/packages/frontend/src/stream.ts +++ b/packages/frontend/src/stream.ts @@ -7,17 +7,20 @@ import * as Misskey from 'misskey-js'; import { markRaw } from 'vue'; import { $i } from '@/account.js'; import { wsOrigin } from '@/config.js'; +// TODO: No Websocketモードã§StreamMockãŒä½¿ãˆãㆠ+//import { StreamMock } from '@/scripts/stream-mock.js'; // heart beat interval in ms const HEART_BEAT_INTERVAL = 1000 * 60; -let stream: Misskey.Stream | null = null; -let timeoutHeartBeat: ReturnType<typeof setTimeout> | null = null; +let stream: Misskey.IStream | null = null; +let timeoutHeartBeat: number | null = null; let lastHeartbeatCall = 0; -export function useStream(): Misskey.Stream { +export function useStream(): Misskey.IStream { if (stream) return stream; + // TODO: No Websocketモードもã“ã“ã§åˆ¤å®š stream = markRaw(new Misskey.Stream(wsOrigin, $i ? { token: $i.token, } : null)); diff --git a/packages/frontend/src/ui/_common_/statusbar-federation.vue b/packages/frontend/src/ui/_common_/statusbar-federation.vue index 8dad6666235daac237d61f9b00e6b511087e0366..e234bb3a33a0393cad69fecbd24786ad33956625 100644 --- a/packages/frontend/src/ui/_common_/statusbar-federation.vue +++ b/packages/frontend/src/ui/_common_/statusbar-federation.vue @@ -35,7 +35,7 @@ import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import MarqueeText from '@/components/MkMarquee.vue'; import { misskeyApi } from '@/scripts/misskey-api.js'; -import { useInterval } from '@/scripts/use-interval.js'; +import { useInterval } from '@@/js/use-interval.js'; import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; const props = defineProps<{ diff --git a/packages/frontend/src/ui/_common_/statusbar-rss.vue b/packages/frontend/src/ui/_common_/statusbar-rss.vue index 6e1d06eec1f99e620334bd66e055c4f9bda66eee..550fc39b001bf57145da603baa7ca3844b020279 100644 --- a/packages/frontend/src/ui/_common_/statusbar-rss.vue +++ b/packages/frontend/src/ui/_common_/statusbar-rss.vue @@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import MarqueeText from '@/components/MkMarquee.vue'; -import { useInterval } from '@/scripts/use-interval.js'; +import { useInterval } from '@@/js/use-interval.js'; import { shuffle } from '@/scripts/shuffle.js'; const props = defineProps<{ diff --git a/packages/frontend/src/ui/_common_/statusbar-user-list.vue b/packages/frontend/src/ui/_common_/statusbar-user-list.vue index 67f8b109c484a6d15855b510867852e0cd05e64e..078b595dca744b2bb830746cf4c9fa2fc238fb15 100644 --- a/packages/frontend/src/ui/_common_/statusbar-user-list.vue +++ b/packages/frontend/src/ui/_common_/statusbar-user-list.vue @@ -35,7 +35,7 @@ import { ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; import MarqueeText from '@/components/MkMarquee.vue'; import { misskeyApi } from '@/scripts/misskey-api.js'; -import { useInterval } from '@/scripts/use-interval.js'; +import { useInterval } from '@@/js/use-interval.js'; import { getNoteSummary } from '@/scripts/get-note-summary.js'; import { notePage } from '@/filters/note.js'; diff --git a/packages/frontend/src/ui/deck/main-column.vue b/packages/frontend/src/ui/deck/main-column.vue index 79c967191709a6e7fb635c4a1d31cbe490a79f37..e7ecf7fd2022f6c949d6edc17c65919e8d8eee27 100644 --- a/packages/frontend/src/ui/deck/main-column.vue +++ b/packages/frontend/src/ui/deck/main-column.vue @@ -26,7 +26,7 @@ import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; import { useScrollPositionManager } from '@/nirax.js'; -import { getScrollContainer } from '@/scripts/scroll.js'; +import { getScrollContainer } from '@@/js/scroll.js'; import { mainRouter } from '@/router/main.js'; defineProps<{ diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue index 073acbd4db7293eff1593fd43e0b4095072b57b5..00a6811fc98d5177340796c198809e7633d2c67b 100644 --- a/packages/frontend/src/ui/universal.vue +++ b/packages/frontend/src/ui/universal.vue @@ -108,7 +108,7 @@ import { $i } from '@/account.js'; import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; import { deviceKind } from '@/scripts/device-kind.js'; import { miLocalStorage } from '@/local-storage.js'; -import { CURRENT_STICKY_BOTTOM } from '@/const.js'; +import { CURRENT_STICKY_BOTTOM } from '@@/js/const.js'; import { useScrollPositionManager } from '@/nirax.js'; import { mainRouter } from '@/router/main.js'; diff --git a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue index 49fd103d37eb60f8ce8ed72ff2f7bcac741b6ab8..bcfaaf00ab4ff10060ae21a5c0af7131bee5370a 100644 --- a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue +++ b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue @@ -25,11 +25,11 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; import * as Misskey from 'misskey-js'; +import { useInterval } from '@@/js/use-interval.js'; import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; import { GetFormResultType } from '@/scripts/form.js'; import MkContainer from '@/components/MkContainer.vue'; import { misskeyApi } from '@/scripts/misskey-api.js'; -import { useInterval } from '@/scripts/use-interval.js'; import { i18n } from '@/i18n.js'; import { infoImageUrl } from '@/instance.js'; import { $i } from '@/account.js'; diff --git a/packages/frontend/src/widgets/WidgetCalendar.vue b/packages/frontend/src/widgets/WidgetCalendar.vue index 6ece33eff3477e9a29cfc734209d035065b65c42..412d5278192d6a45a1c0a49b50d656cdd696a167 100644 --- a/packages/frontend/src/widgets/WidgetCalendar.vue +++ b/packages/frontend/src/widgets/WidgetCalendar.vue @@ -42,7 +42,7 @@ import { ref } from 'vue'; import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; import { GetFormResultType } from '@/scripts/form.js'; import { i18n } from '@/i18n.js'; -import { useInterval } from '@/scripts/use-interval.js'; +import { useInterval } from '@@/js/use-interval.js'; const name = 'calendar'; diff --git a/packages/frontend/src/widgets/WidgetFederation.vue b/packages/frontend/src/widgets/WidgetFederation.vue index ed907de9b8db0abf90b92dca7a6fa89a68cfca61..c10416e4fbf0c7c96db6df9f2a4c766cb39e6953 100644 --- a/packages/frontend/src/widgets/WidgetFederation.vue +++ b/packages/frontend/src/widgets/WidgetFederation.vue @@ -32,7 +32,7 @@ import { GetFormResultType } from '@/scripts/form.js'; import MkContainer from '@/components/MkContainer.vue'; import MkMiniChart from '@/components/MkMiniChart.vue'; import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; -import { useInterval } from '@/scripts/use-interval.js'; +import { useInterval } from '@@/js/use-interval.js'; import { i18n } from '@/i18n.js'; import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; import { defaultStore } from '@/store.js'; diff --git a/packages/frontend/src/widgets/WidgetInstanceCloud.vue b/packages/frontend/src/widgets/WidgetInstanceCloud.vue index 76ccdb397119a7e5516763a49008fd34b6e825e2..d090372b9a26a9cd31317a21d9e0322288655795 100644 --- a/packages/frontend/src/widgets/WidgetInstanceCloud.vue +++ b/packages/frontend/src/widgets/WidgetInstanceCloud.vue @@ -26,7 +26,7 @@ import MkContainer from '@/components/MkContainer.vue'; import MkTagCloud from '@/components/MkTagCloud.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; -import { useInterval } from '@/scripts/use-interval.js'; +import { useInterval } from '@@/js/use-interval.js'; import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; const name = 'instanceCloud'; diff --git a/packages/frontend/src/widgets/WidgetOnlineUsers.vue b/packages/frontend/src/widgets/WidgetOnlineUsers.vue index 5c89a06c62062dd03f92c41d4ce768d09b3a1049..d56ee96ac1751e374eb1cbaefa35e1a03f5a062c 100644 --- a/packages/frontend/src/widgets/WidgetOnlineUsers.vue +++ b/packages/frontend/src/widgets/WidgetOnlineUsers.vue @@ -18,7 +18,7 @@ import { ref } from 'vue'; import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; import { GetFormResultType } from '@/scripts/form.js'; import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js'; -import { useInterval } from '@/scripts/use-interval.js'; +import { useInterval } from '@@/js/use-interval.js'; import { i18n } from '@/i18n.js'; import number from '@/filters/number.js'; diff --git a/packages/frontend/src/widgets/WidgetRss.vue b/packages/frontend/src/widgets/WidgetRss.vue index e5758662cc49479c8ef5b79ff5b8f8bbb7d1a2ce..13f5a4802a75ecc2d221dc6cf0a5754b9c947d33 100644 --- a/packages/frontend/src/widgets/WidgetRss.vue +++ b/packages/frontend/src/widgets/WidgetRss.vue @@ -30,7 +30,7 @@ import { GetFormResultType } from '@/scripts/form.js'; import MkContainer from '@/components/MkContainer.vue'; import { url as base } from '@/config.js'; import { i18n } from '@/i18n.js'; -import { useInterval } from '@/scripts/use-interval.js'; +import { useInterval } from '@@/js/use-interval.js'; import { infoImageUrl } from '@/instance.js'; const name = 'rss'; diff --git a/packages/frontend/src/widgets/WidgetRssTicker.vue b/packages/frontend/src/widgets/WidgetRssTicker.vue index 16306ef5ba9b39ca8fed919115d266aba9a1f576..51f1cac97f39df14702f824877f50018181c1ba3 100644 --- a/packages/frontend/src/widgets/WidgetRssTicker.vue +++ b/packages/frontend/src/widgets/WidgetRssTicker.vue @@ -35,7 +35,7 @@ import { GetFormResultType } from '@/scripts/form.js'; import MkContainer from '@/components/MkContainer.vue'; import { shuffle } from '@/scripts/shuffle.js'; import { url as base } from '@/config.js'; -import { useInterval } from '@/scripts/use-interval.js'; +import { useInterval } from '@@/js/use-interval.js'; const name = 'rssTicker'; diff --git a/packages/frontend/src/widgets/WidgetSlideshow.vue b/packages/frontend/src/widgets/WidgetSlideshow.vue index b8efd3bda9fc7808ab0a5bfe339da3b34d444b22..3fea1d70534429af206a158e11e926fe0fe505e8 100644 --- a/packages/frontend/src/widgets/WidgetSlideshow.vue +++ b/packages/frontend/src/widgets/WidgetSlideshow.vue @@ -23,7 +23,7 @@ import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, Wid import { GetFormResultType } from '@/scripts/form.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; -import { useInterval } from '@/scripts/use-interval.js'; +import { useInterval } from '@@/js/use-interval.js'; import { i18n } from '@/i18n.js'; const name = 'slideshow'; diff --git a/packages/frontend/src/widgets/WidgetTrends.vue b/packages/frontend/src/widgets/WidgetTrends.vue index 4299181a273f93c23758e6cd3325bf12df0c1377..a41db513e8d1eeca371bd557d5bef3d87657b1a5 100644 --- a/packages/frontend/src/widgets/WidgetTrends.vue +++ b/packages/frontend/src/widgets/WidgetTrends.vue @@ -31,7 +31,7 @@ import { GetFormResultType } from '@/scripts/form.js'; import MkContainer from '@/components/MkContainer.vue'; import MkMiniChart from '@/components/MkMiniChart.vue'; import { misskeyApiGet } from '@/scripts/misskey-api.js'; -import { useInterval } from '@/scripts/use-interval.js'; +import { useInterval } from '@@/js/use-interval.js'; import { i18n } from '@/i18n.js'; import { defaultStore } from '@/store.js'; diff --git a/packages/frontend/src/widgets/WidgetUserList.vue b/packages/frontend/src/widgets/WidgetUserList.vue index d9f4dc49ea87aeeb621e6e111470383f3c4d184c..72391d622eeb3b470f74bd2362c23acd3478d48f 100644 --- a/packages/frontend/src/widgets/WidgetUserList.vue +++ b/packages/frontend/src/widgets/WidgetUserList.vue @@ -31,7 +31,7 @@ import { GetFormResultType } from '@/scripts/form.js'; import MkContainer from '@/components/MkContainer.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; -import { useInterval } from '@/scripts/use-interval.js'; +import { useInterval } from '@@/js/use-interval.js'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; diff --git a/packages/frontend/test/emoji.test.ts b/packages/frontend/test/emoji.test.ts index 9a2989b37348794c5347efab6eb85d4cc23cb4f5..cf686efd0d127651a1aaadc142ce0be00087547e 100644 --- a/packages/frontend/test/emoji.test.ts +++ b/packages/frontend/test/emoji.test.ts @@ -6,7 +6,7 @@ import { describe, test, assert, afterEach } from 'vitest'; import { render, cleanup, type RenderResult } from '@testing-library/vue'; import { defaultStoreState } from './init.js'; -import { getEmojiName } from '@/scripts/emojilist.js'; +import { getEmojiName } from '@@/js/emojilist.js'; import { components } from '@/components/index.js'; import { directives } from '@/directives/index.js'; import MkEmoji from '@/components/global/MkEmoji.vue'; diff --git a/packages/frontend/test/i18n.test.ts b/packages/frontend/test/i18n.test.ts index e1cab1f15fd2683a80bd2f66d119dc4157737e77..9d6cf855f340a91ae179cbd3846d50862065f1e5 100644 --- a/packages/frontend/test/i18n.test.ts +++ b/packages/frontend/test/i18n.test.ts @@ -4,9 +4,11 @@ */ import { describe, expect, it } from 'vitest'; -import { I18n } from '@/scripts/i18n.js'; +import { I18n } from '../../frontend-shared/js/i18n.js'; // @@ã§å‚ç…§ã§ããªã‹ã£ãŸã®ã§ import { ParameterizedString } from '../../../locales/index.js'; +// TODO: ã“ã®ãƒ†ã‚¹ãƒˆã¯frontend-sharedã«ç§»å‹•ã™ã‚‹ + describe('i18n', () => { it('t', () => { const i18n = new I18n({ diff --git a/packages/frontend/test/scroll.test.ts b/packages/frontend/test/scroll.test.ts index a0b56b7221bbadb5cd63b7af51e5bbf425072ad2..32a5a1c55811931b67d6e5c85de8cb6fd6b6f709 100644 --- a/packages/frontend/test/scroll.test.ts +++ b/packages/frontend/test/scroll.test.ts @@ -5,7 +5,7 @@ import { describe, test, assert, afterEach } from 'vitest'; import { Window } from 'happy-dom'; -import { onScrollBottom, onScrollTop } from '@/scripts/scroll.js'; +import { onScrollBottom, onScrollTop } from '@@/js/scroll.js'; describe('Scroll', () => { describe('onScrollTop', () => { diff --git a/packages/frontend/tsconfig.json b/packages/frontend/tsconfig.json index fe4d20289492776252989f792ada957b8d70ce47..b88773b598c6e825afb26f7ce9ec96996b710a07 100644 --- a/packages/frontend/tsconfig.json +++ b/packages/frontend/tsconfig.json @@ -23,7 +23,8 @@ "useDefineForClassFields": true, "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@@/*": ["../frontend-shared/*"] }, "typeRoots": [ "./@types", diff --git a/packages/frontend/vite.config.local-dev.ts b/packages/frontend/vite.config.local-dev.ts index 887ab7927e9f84ad1866459282f547cd46487e55..922fb459958fed81f234be389106469c9d99a099 100644 --- a/packages/frontend/vite.config.local-dev.ts +++ b/packages/frontend/vite.config.local-dev.ts @@ -15,6 +15,7 @@ const { port } = yaml.load(await readFile('../../.config/default.yml', 'utf-8')) const httpUrl = `http://localhost:${port}/`; const websocketUrl = `ws://localhost:${port}/`; +const embedUrl = `http://localhost:5174/`; // activitypubリクエストã¯Proxyを通ã—ã€ãれ以外ã¯Viteã®é–‹ç™ºã‚µãƒ¼ãƒãƒ¼ã‚’返㙠function varyHandler(req: IncomingMessage) { @@ -50,6 +51,12 @@ const devConfig: UserConfig = { ws: true, }, '/favicon.ico': httpUrl, + '/robots.txt': httpUrl, + '/embed.js': httpUrl, + '/embed': { + target: embedUrl, + ws: true, + }, '/identicon': { target: httpUrl, rewrite(path) { diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index 6decbc0ef70096915c73118f430113488410ab38..e982df8ffd7ee898336a9474b58417bea4b03acf 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -65,6 +65,9 @@ export function getConfig(): UserConfig { server: { port: 5173, + headers: { // ãªã‚“ã‹åŠ¹ã‹ãªã„ + 'X-Frame-Options': 'DENY', + }, }, plugins: [ @@ -87,6 +90,7 @@ export function getConfig(): UserConfig { extensions, alias: { '@/': __dirname + '/src/', + '@@/': __dirname + '/../frontend-shared/', '/client-assets/': __dirname + '/assets/', '/static-assets/': __dirname + '/../backend/assets/', '/fluent-emojis/': __dirname + '/../../fluent-emojis/dist/', @@ -151,7 +155,7 @@ export function getConfig(): UserConfig { }, }, cssCodeSplit: true, - outDir: __dirname + '/../../built/_vite_', + outDir: __dirname + '/../../built/_frontend_vite_', assetsDir: '.', emptyOutDir: false, sourcemap: process.env.NODE_ENV === 'development', diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index aaaa0493ca493b53cb85f5374e664b13fb1f6084..1ec7f0ec7f392545a4da78352364ba1448f0e07a 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -551,7 +551,7 @@ type Channel = components['schemas']['Channel']; // Warning: (ae-forgotten-export) The symbol "AnyOf" needs to be exported by the entry point index.d.ts // // @public (undocumented) -export abstract class ChannelConnection<Channel extends AnyOf<Channels> = AnyOf<Channels>> extends EventEmitter<Channel['events']> { +export abstract class ChannelConnection<Channel extends AnyOf<Channels> = AnyOf<Channels>> extends EventEmitter<Channel['events']> implements IChannelConnection<Channel> { constructor(stream: Stream, channel: string, name?: string); // (undocumented) channel: string; @@ -2119,6 +2119,24 @@ type IAuthorizedAppsResponse = operations['i___authorized-apps']['responses']['2 // @public (undocumented) type IChangePasswordRequest = operations['i___change-password']['requestBody']['content']['application/json']; +// @public (undocumented) +export interface IChannelConnection<Channel extends AnyOf<Channels> = AnyOf<Channels>> extends EventEmitter<Channel['events']> { + // (undocumented) + channel: string; + // (undocumented) + dispose(): void; + // (undocumented) + id: string; + // (undocumented) + inCount: number; + // (undocumented) + name?: string; + // (undocumented) + outCount: number; + // (undocumented) + send<T extends keyof Channel['receives']>(type: T, body: Channel['receives'][T]): void; +} + // @public (undocumented) type IClaimAchievementRequest = operations['i___claim-achievement']['requestBody']['content']['application/json']; @@ -2281,6 +2299,40 @@ type ISigninHistoryResponse = operations['i___signin-history']['responses']['200 // @public (undocumented) function isPureRenote(note: Note): note is PureRenote; +// @public (undocumented) +export interface IStream extends EventEmitter<StreamEvents> { + // (undocumented) + close(): void; + // Warning: (ae-forgotten-export) The symbol "NonSharedConnection" needs to be exported by the entry point index.d.ts + // + // (undocumented) + disconnectToChannel(connection: NonSharedConnection): void; + // (undocumented) + heartbeat(): void; + // (undocumented) + ping(): void; + // Warning: (ae-forgotten-export) The symbol "SharedConnection" needs to be exported by the entry point index.d.ts + // + // (undocumented) + removeSharedConnection(connection: SharedConnection): void; + // Warning: (ae-forgotten-export) The symbol "Pool" needs to be exported by the entry point index.d.ts + // + // (undocumented) + removeSharedConnectionPool(pool: Pool): void; + // (undocumented) + send(typeOrPayload: string): void; + // (undocumented) + send(typeOrPayload: string, payload: unknown): void; + // (undocumented) + send(typeOrPayload: Record<string, unknown> | unknown[]): void; + // (undocumented) + send(typeOrPayload: string | Record<string, unknown> | unknown[], payload?: unknown): void; + // (undocumented) + state: 'initializing' | 'reconnecting' | 'connected'; + // (undocumented) + useChannel<C extends keyof Channels>(channel: C, params?: Channels[C]['params'], name?: string): IChannelConnection<Channels[C]>; +} + // @public (undocumented) type IUnpinRequest = operations['i___unpin']['requestBody']['content']['application/json']; @@ -2707,6 +2759,9 @@ type NotificationsCreateRequest = operations['notifications___create']['requestB // @public (undocumented) export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollVote", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app", "roleAssigned", "achievementEarned"]; +// @public (undocumented) +export function nyaize(text: string): string; + // @public (undocumented) type Page = components['schemas']['Page']; @@ -2997,10 +3052,8 @@ type SignupResponse = MeDetailed & { // @public (undocumented) type StatsResponse = operations['stats']['responses']['200']['content']['application/json']; -// Warning: (ae-forgotten-export) The symbol "StreamEvents" needs to be exported by the entry point index.d.ts -// // @public (undocumented) -export class Stream extends EventEmitter<StreamEvents> { +export class Stream extends EventEmitter<StreamEvents> implements IStream { constructor(origin: string, user: { token: string; } | null, options?: { @@ -3008,20 +3061,14 @@ export class Stream extends EventEmitter<StreamEvents> { }); // (undocumented) close(): void; - // Warning: (ae-forgotten-export) The symbol "NonSharedConnection" needs to be exported by the entry point index.d.ts - // // (undocumented) disconnectToChannel(connection: NonSharedConnection): void; // (undocumented) heartbeat(): void; // (undocumented) ping(): void; - // Warning: (ae-forgotten-export) The symbol "SharedConnection" needs to be exported by the entry point index.d.ts - // // (undocumented) removeSharedConnection(connection: SharedConnection): void; - // Warning: (ae-forgotten-export) The symbol "Pool" needs to be exported by the entry point index.d.ts - // // (undocumented) removeSharedConnectionPool(pool: Pool): void; // (undocumented) @@ -3036,6 +3083,14 @@ export class Stream extends EventEmitter<StreamEvents> { useChannel<C extends keyof Channels>(channel: C, params?: Channels[C]['params'], name?: string): ChannelConnection<Channels[C]>; } +// Warning: (ae-forgotten-export) The symbol "BroadcastEvents" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type StreamEvents = { + _connected_: void; + _disconnected_: void; +} & BroadcastEvents; + // Warning: (ae-forgotten-export) The symbol "SwitchCase" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "IsCaseMatched" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "GetCaseResult" needs to be exported by the entry point index.d.ts diff --git a/packages/misskey-js/src/index.ts b/packages/misskey-js/src/index.ts index ace9738e6a19ffd6d2e6a253dbc54455ee09df54..e4c9364aa1c2a0a184e45061020d75ce14fb6692 100644 --- a/packages/misskey-js/src/index.ts +++ b/packages/misskey-js/src/index.ts @@ -1,15 +1,6 @@ -import { type Endpoints } from './api.types.js'; import Stream, { Connection } from './streaming.js'; -import { type Channels } from './streaming.types.js'; -import { type Acct } from './acct.js'; import * as consts from './consts.js'; -export type { - Endpoints, - Channels, - Acct, -}; - export { Stream, Connection as ChannelConnection, @@ -31,4 +22,21 @@ import * as api from './api.js'; import * as entities from './entities.js'; import * as acct from './acct.js'; import * as note from './note.js'; -export { api, entities, acct, note }; +import { nyaize } from './nyaize.js'; +export { api, entities, acct, note, nyaize }; + +//#region standalone types +import type { Endpoints } from './api.types.js'; +import type { StreamEvents, IStream, IChannelConnection } from './streaming.js'; +import type { Channels } from './streaming.types.js'; +import type { Acct } from './acct.js'; + +export type { + Endpoints, + Channels, + Acct, + StreamEvents, + IStream, + IChannelConnection, +}; +//#endregion diff --git a/packages/frontend/src/scripts/nyaize.ts b/packages/misskey-js/src/nyaize.ts similarity index 82% rename from packages/frontend/src/scripts/nyaize.ts rename to packages/misskey-js/src/nyaize.ts index abc8ada4617182416f0170eb9eb07edc64924711..729fea8fc37f92e7bb739c82959af37935f61584 100644 --- a/packages/frontend/src/scripts/nyaize.ts +++ b/packages/misskey-js/src/nyaize.ts @@ -19,9 +19,9 @@ export function nyaize(text: string): string { .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(koRegex1, match => !isNaN(match.charCodeAt(0)) ? String.fromCharCode( + match.charCodeAt(0) + 'ëƒ'.charCodeAt(0) - '나'.charCodeAt(0), + ) : match) .replace(koRegex2, '다냥') .replace(koRegex3, '냥'); } diff --git a/packages/misskey-js/src/streaming.ts b/packages/misskey-js/src/streaming.ts index d1d131cfc134d95aa34416dff09817bd989d8ed9..ffb46c77f6c179002023228d804f5a3d78247291 100644 --- a/packages/misskey-js/src/streaming.ts +++ b/packages/misskey-js/src/streaming.ts @@ -17,16 +17,32 @@ export function urlQuery(obj: Record<string, string | number | boolean | undefin type AnyOf<T extends Record<PropertyKey, unknown>> = T[keyof T]; -type StreamEvents = { +export type StreamEvents = { _connected_: void; _disconnected_: void; } & BroadcastEvents; +export interface IStream extends EventEmitter<StreamEvents> { + state: 'initializing' | 'reconnecting' | 'connected'; + + useChannel<C extends keyof Channels>(channel: C, params?: Channels[C]['params'], name?: string): IChannelConnection<Channels[C]>; + removeSharedConnection(connection: SharedConnection): void; + removeSharedConnectionPool(pool: Pool): void; + disconnectToChannel(connection: NonSharedConnection): void; + send(typeOrPayload: string): void; + send(typeOrPayload: string, payload: unknown): void; + send(typeOrPayload: Record<string, unknown> | unknown[]): void; + send(typeOrPayload: string | Record<string, unknown> | unknown[], payload?: unknown): void; + ping(): void; + heartbeat(): void; + close(): void; +} + /** * Misskey stream connection */ // eslint-disable-next-line import/no-default-export -export default class Stream extends EventEmitter<StreamEvents> { +export default class Stream extends EventEmitter<StreamEvents> implements IStream { private stream: _ReconnectingWebsocket.default; public state: 'initializing' | 'reconnecting' | 'connected' = 'initializing'; private sharedConnectionPools: Pool[] = []; @@ -277,7 +293,18 @@ class Pool { } } -export abstract class Connection<Channel extends AnyOf<Channels> = AnyOf<Channels>> extends EventEmitter<Channel['events']> { +export interface IChannelConnection<Channel extends AnyOf<Channels> = AnyOf<Channels>> extends EventEmitter<Channel['events']> { + id: string; + name?: string; + inCount: number; + outCount: number; + channel: string; + + send<T extends keyof Channel['receives']>(type: T, body: Channel['receives'][T]): void; + dispose(): void; +} + +export abstract class Connection<Channel extends AnyOf<Channels> = AnyOf<Channels>> extends EventEmitter<Channel['events']> implements IChannelConnection<Channel> { public channel: string; protected stream: Stream; public abstract id: string; diff --git a/packages/sw/src/scripts/lang.ts b/packages/sw/src/scripts/lang.ts index 0db4cc6381813ceb4771b49656961b8d4b505c24..3000160e41fdebad72abd26239fe2a3a52de55fc 100644 --- a/packages/sw/src/scripts/lang.ts +++ b/packages/sw/src/scripts/lang.ts @@ -7,7 +7,7 @@ * Language manager for SW */ import { get, set } from 'idb-keyval'; -import { I18n } from '../../../frontend/src/scripts/i18n.js'; +import { I18n } from '@@/js/i18n.js'; import type { Locale } from '../../../../locales/index.js'; class SwLang { diff --git a/packages/sw/src/sw.ts b/packages/sw/src/sw.ts index 2d39d23ec744ee8253c6d2a656ba91786060cd4c..bf980b83a4878e1b6cf31876b34f962250ea2b4d 100644 --- a/packages/sw/src/sw.ts +++ b/packages/sw/src/sw.ts @@ -6,7 +6,7 @@ import { get } from 'idb-keyval'; import * as Misskey from 'misskey-js'; import type { PushNotificationDataMap } from '@/types.js'; -import type { I18n } from '../../frontend/src/scripts/i18n.js'; +import type { I18n } from '@@/js/i18n.js'; import type { Locale } from '../../../locales/index.js'; import { createEmptyNotification, createNotification } from '@/scripts/create-notification.js'; import { swLang } from '@/scripts/lang.js'; diff --git a/packages/sw/tsconfig.json b/packages/sw/tsconfig.json index f3f354301387748545dc3d119888ae5f378a8e2b..2712475a3744393427fe689bcc02219ced46ade0 100644 --- a/packages/sw/tsconfig.json +++ b/packages/sw/tsconfig.json @@ -21,7 +21,8 @@ "isolatedModules": true, "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@@/*": ["../frontend-shared/*"] }, "typeRoots": [ "./node_modules/@types", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71ce6f6c630cfbf93271f4eb302c32d9388d55ea..60842367fbfa0147a1cd09fee6d9f597e22da258 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -775,6 +775,9 @@ importers: eventemitter3: specifier: 5.0.1 version: 5.0.1 + frontend-shared: + specifier: workspace:* + version: link:../frontend-shared idb-keyval: specifier: 6.2.1 version: 6.2.1 @@ -1044,6 +1047,245 @@ importers: specifier: 2.0.29 version: 2.0.29(typescript@5.5.4) + packages/frontend-embed: + dependencies: + '@discordapp/twemoji': + specifier: 15.0.3 + version: 15.0.3 + '@github/webauthn-json': + specifier: 2.1.1 + version: 2.1.1 + '@rollup/plugin-json': + specifier: 6.1.0 + version: 6.1.0(rollup@4.19.1) + '@rollup/plugin-replace': + specifier: 5.0.7 + version: 5.0.7(rollup@4.19.1) + '@rollup/pluginutils': + specifier: 5.1.0 + version: 5.1.0(rollup@4.19.1) + '@tabler/icons-webfont': + specifier: 3.3.0 + version: 3.3.0 + '@twemoji/parser': + specifier: 15.1.1 + version: 15.1.1 + '@vitejs/plugin-vue': + specifier: 5.1.0 + version: 5.1.0(vite@5.3.5(@types/node@20.14.12)(sass@1.77.8)(terser@5.31.3))(vue@3.4.37(typescript@5.5.4)) + '@vue/compiler-sfc': + specifier: 3.4.37 + version: 3.4.37 + astring: + specifier: 1.8.6 + version: 1.8.6 + buraha: + specifier: 0.0.1 + version: 0.0.1 + compare-versions: + specifier: 6.1.1 + version: 6.1.1 + date-fns: + specifier: 2.30.0 + version: 2.30.0 + escape-regexp: + specifier: 0.0.1 + version: 0.0.1 + estree-walker: + specifier: 3.0.3 + version: 3.0.3 + eventemitter3: + specifier: 5.0.1 + version: 5.0.1 + frontend-shared: + specifier: workspace:* + version: link:../frontend-shared + idb-keyval: + specifier: 6.2.1 + version: 6.2.1 + is-file-animated: + specifier: 1.0.2 + version: 1.0.2 + json5: + specifier: 2.2.3 + version: 2.2.3 + mfm-js: + specifier: 0.24.0 + version: 0.24.0 + misskey-js: + specifier: workspace:* + version: link:../misskey-js + punycode: + specifier: 2.3.1 + version: 2.3.1 + rollup: + specifier: 4.19.1 + version: 4.19.1 + sanitize-html: + specifier: 2.13.0 + version: 2.13.0 + sass: + specifier: 1.77.8 + version: 1.77.8 + shiki: + specifier: 1.12.0 + version: 1.12.0 + strict-event-emitter-types: + specifier: 2.0.0 + version: 2.0.0 + throttle-debounce: + specifier: 5.0.2 + version: 5.0.2 + tinycolor2: + specifier: 1.6.0 + version: 1.6.0 + tsc-alias: + specifier: 1.8.10 + version: 1.8.10 + tsconfig-paths: + specifier: 4.2.0 + version: 4.2.0 + typescript: + specifier: 5.5.4 + version: 5.5.4 + uuid: + specifier: 10.0.0 + version: 10.0.0 + vite: + specifier: 5.3.5 + version: 5.3.5(@types/node@20.14.12)(sass@1.77.8)(terser@5.31.3) + vue: + specifier: 3.4.37 + version: 3.4.37(typescript@5.5.4) + devDependencies: + '@misskey-dev/summaly': + specifier: 5.1.0 + version: 5.1.0 + '@testing-library/vue': + specifier: 8.1.0 + version: 8.1.0(@vue/compiler-sfc@3.4.37)(@vue/server-renderer@3.4.37(vue@3.4.37(typescript@5.5.4)))(vue@3.4.37(typescript@5.5.4)) + '@types/escape-regexp': + specifier: 0.0.3 + version: 0.0.3 + '@types/estree': + specifier: 1.0.5 + version: 1.0.5 + '@types/micromatch': + specifier: 4.0.9 + version: 4.0.9 + '@types/node': + specifier: 20.14.12 + version: 20.14.12 + '@types/punycode': + specifier: 2.1.4 + version: 2.1.4 + '@types/sanitize-html': + specifier: 2.11.0 + version: 2.11.0 + '@types/throttle-debounce': + specifier: 5.0.2 + version: 5.0.2 + '@types/tinycolor2': + specifier: 1.4.6 + version: 1.4.6 + '@types/uuid': + specifier: 10.0.0 + version: 10.0.0 + '@types/ws': + specifier: 8.5.11 + version: 8.5.11 + '@typescript-eslint/eslint-plugin': + specifier: 7.17.0 + version: 7.17.0(@typescript-eslint/parser@7.17.0(eslint@9.8.0)(typescript@5.5.4))(eslint@9.8.0)(typescript@5.5.4) + '@typescript-eslint/parser': + specifier: 7.17.0 + version: 7.17.0(eslint@9.8.0)(typescript@5.5.4) + '@vitest/coverage-v8': + specifier: 1.6.0 + version: 1.6.0(vitest@1.6.0(@types/node@20.14.12)(happy-dom@10.0.3)(jsdom@24.1.1)(sass@1.77.8)(terser@5.31.3)) + '@vue/runtime-core': + specifier: 3.4.37 + version: 3.4.37 + acorn: + specifier: 8.12.1 + version: 8.12.1 + cross-env: + specifier: 7.0.3 + version: 7.0.3 + eslint-plugin-import: + specifier: 2.29.1 + version: 2.29.1(@typescript-eslint/parser@7.17.0(eslint@9.8.0)(typescript@5.5.4))(eslint@9.8.0) + eslint-plugin-vue: + specifier: 9.27.0 + version: 9.27.0(eslint@9.8.0) + fast-glob: + specifier: 3.3.2 + version: 3.3.2 + happy-dom: + specifier: 10.0.3 + version: 10.0.3 + intersection-observer: + specifier: 0.12.2 + version: 0.12.2 + micromatch: + specifier: 4.0.7 + version: 4.0.7 + msw: + specifier: 2.3.4 + version: 2.3.4(typescript@5.5.4) + nodemon: + specifier: 3.1.4 + version: 3.1.4 + prettier: + specifier: 3.3.3 + version: 3.3.3 + start-server-and-test: + specifier: 2.0.4 + version: 2.0.4 + vite-plugin-turbosnap: + specifier: 1.0.3 + version: 1.0.3 + vue-component-type-helpers: + specifier: 2.0.29 + version: 2.0.29 + vue-eslint-parser: + specifier: 9.4.3 + version: 9.4.3(eslint@9.8.0) + vue-tsc: + specifier: 2.0.29 + version: 2.0.29(typescript@5.5.4) + + packages/frontend-shared: + dependencies: + misskey-js: + specifier: workspace:* + version: link:../misskey-js + vue: + specifier: 3.4.37 + version: 3.4.37(typescript@5.5.4) + devDependencies: + '@types/node': + specifier: 20.14.12 + version: 20.14.12 + '@typescript-eslint/eslint-plugin': + specifier: 7.17.0 + version: 7.17.0(@typescript-eslint/parser@7.17.0(eslint@9.8.0)(typescript@5.5.4))(eslint@9.8.0)(typescript@5.5.4) + '@typescript-eslint/parser': + specifier: 7.17.0 + version: 7.17.0(eslint@9.8.0)(typescript@5.5.4) + esbuild: + specifier: 0.23.0 + version: 0.23.0 + eslint-plugin-vue: + specifier: 9.27.0 + version: 9.27.0(eslint@9.8.0) + typescript: + specifier: 5.5.4 + version: 5.5.4 + vue-eslint-parser: + specifier: 9.4.3 + version: 9.4.3(eslint@9.8.0) + packages/misskey-bubble-game: dependencies: eventemitter3: @@ -5380,15 +5622,9 @@ packages: '@vue/compiler-core@3.4.31': resolution: {integrity: sha512-skOiodXWTV3DxfDhB4rOf3OGalpITLlgCeOwb+Y9GJpfQ8ErigdBUHomBzvG78JoVE8MJoQsb+qhZiHfKeNeEg==} - '@vue/compiler-core@3.4.34': - resolution: {integrity: sha512-Z0izUf32+wAnQewjHu+pQf1yw00EGOmevl1kE+ljjjMe7oEfpQ+BI3/JNK7yMB4IrUsqLDmPecUrpj3mCP+yJQ==} - '@vue/compiler-core@3.4.37': resolution: {integrity: sha512-ZDDT/KiLKuCRXyzWecNzC5vTcubGz4LECAtfGPENpo0nrmqJHwuWtRLxk/Sb9RAKtR9iFflFycbkjkY+W/PZUQ==} - '@vue/compiler-dom@3.4.34': - resolution: {integrity: sha512-3PUOTS1h5cskdOJMExCu2TInXuM0j60DRPpSCJDqOCupCfUZCJoyQmKtRmA8EgDNZ5kcEE7vketamRZfrEuVDw==} - '@vue/compiler-dom@3.4.37': resolution: {integrity: sha512-rIiSmL3YrntvgYV84rekAtU/xfogMUJIclUMeIKEtVBFngOL3IeZHhsH3UaFEgB5iFGpj6IW+8YuM/2Up+vVag==} @@ -5437,9 +5673,6 @@ packages: '@vue/shared@3.4.31': resolution: {integrity: sha512-Yp3wtJk//8cO4NItOPpi3QkLExAr/aLBGZMmTtW9WpdwBCJpRM6zj9WgWktXAl8IDIozwNMByT45JP3tO3ACWA==} - '@vue/shared@3.4.34': - resolution: {integrity: sha512-x5LmiRLpRsd9KTjAB8MPKf0CDPMcuItjP0gbNqFCIgL1I8iYp4zglhj9w9FPCdIbHG2M91RVeIbArFfFTz9I3A==} - '@vue/shared@3.4.37': resolution: {integrity: sha512-nIh8P2fc3DflG8+5Uw8PT/1i17ccFn0xxN/5oE9RfV5SVnd7G0XEFRwakrnNFE/jlS95fpGXDVG5zDETS26nmg==} @@ -6985,10 +7218,6 @@ packages: engines: {node: '>=4'} hasBin: true - esquery@1.4.2: - resolution: {integrity: sha512-JVSoLdTlTDkmjFmab7H/9SL9qGSyjElT3myyKp7krqjVFQCDLmj1QFaCLRFBszBKI0XVZaiiXvuPIX3ZwHe1Ng==} - engines: {node: '>=0.10'} - esquery@1.6.0: resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} engines: {node: '>=0.10'} @@ -7804,6 +8033,7 @@ packages: inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -9198,10 +9428,6 @@ packages: resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} engines: {node: '>=10'} - normalize-url@8.0.0: - resolution: {integrity: sha512-uVFpKhj5MheNBJRTiMZ9pE/7hD1QTeEvugSJW/OmLzAp78PB5O6adfMNTvmfKhXBkvCzC+rqifWcVYpGFwTjnw==} - engines: {node: '>=14.16'} - normalize-url@8.0.1: resolution: {integrity: sha512-IO9QvjUMWxPQQhs60oOu10CRkWCiZzSUkzbXGGV9pviYl1fXYcvkzQ5jV9z8Y6un8ARoVRl4EtC6v6jNqbaJ/w==} engines: {node: '>=14.16'} @@ -9814,10 +10040,6 @@ packages: peerDependencies: postcss: ^8.4.31 - postcss-selector-parser@6.0.15: - resolution: {integrity: sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==} - engines: {node: '>=4'} - postcss-selector-parser@6.0.16: resolution: {integrity: sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==} engines: {node: '>=4'} @@ -9837,10 +10059,6 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.4.38: - resolution: {integrity: sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.4.40: resolution: {integrity: sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==} engines: {node: ^10 || ^12 || >=14} @@ -11115,7 +11333,6 @@ packages: ts-case-convert@2.0.2: resolution: {integrity: sha512-vdKfx1VAdpvEBOBv5OpVu5ZFqRg9HdTI4sYt6qqMeICBeNyXvitrarCnFWNDAki51IKwCyx+ZssY46Q9jH5otA==} - bundledDependencies: [] ts-dedent@2.2.0: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} @@ -11596,6 +11813,9 @@ packages: vue-component-type-helpers@2.0.29: resolution: {integrity: sha512-58i+ZhUAUpwQ+9h5Hck0D+jr1qbYl4voRt5KffBx8qzELViQ4XdT/Tuo+mzq8u63teAG8K0lLaOiL5ofqW38rg==} + vue-component-type-helpers@2.1.6: + resolution: {integrity: sha512-ng11B8B/ZADUMMOsRbqv0arc442q7lifSubD0v8oDXIFoMg/mXwAPUunrroIDkY+mcD0dHKccdaznSVp8EoX3w==} + vue-demi@0.14.7: resolution: {integrity: sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==} engines: {node: '>=12'} @@ -11909,8 +12129,8 @@ snapshots: '@ampproject/remapping@2.2.1': dependencies: - '@jridgewell/gen-mapping': 0.3.2 - '@jridgewell/trace-mapping': 0.3.18 + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 '@apidevtools/openapi-schemas@2.1.0': {} @@ -12435,7 +12655,7 @@ snapshots: '@babel/code-frame@7.24.7': dependencies: '@babel/highlight': 7.24.7 - picocolors: 1.0.0 + picocolors: 1.0.1 '@babel/compat-data@7.23.5': {} @@ -12454,7 +12674,7 @@ snapshots: '@babel/traverse': 7.23.5 '@babel/types': 7.24.7 convert-source-map: 2.0.0 - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -12474,7 +12694,7 @@ snapshots: '@babel/traverse': 7.24.7 '@babel/types': 7.24.7 convert-source-map: 2.0.0 - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -12549,7 +12769,7 @@ snapshots: '@babel/core': 7.24.7 '@babel/helper-compilation-targets': 7.24.7 '@babel/helper-plugin-utils': 7.24.7 - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) lodash.debounce: 4.0.8 resolve: 1.22.8 transitivePeerDependencies: @@ -12710,7 +12930,7 @@ snapshots: '@babel/helper-validator-identifier': 7.24.7 chalk: 2.4.2 js-tokens: 4.0.0 - picocolors: 1.0.0 + picocolors: 1.0.1 '@babel/parser@7.24.7': dependencies: @@ -13390,7 +13610,7 @@ snapshots: '@babel/template@7.22.15': dependencies: - '@babel/code-frame': 7.23.5 + '@babel/code-frame': 7.24.7 '@babel/parser': 7.24.7 '@babel/types': 7.24.7 @@ -13408,7 +13628,7 @@ snapshots: '@babel/traverse@7.23.5': dependencies: - '@babel/code-frame': 7.23.5 + '@babel/code-frame': 7.24.7 '@babel/generator': 7.23.5 '@babel/helper-environment-visitor': 7.22.20 '@babel/helper-function-name': 7.23.0 @@ -13416,7 +13636,7 @@ snapshots: '@babel/helper-split-export-declaration': 7.22.6 '@babel/parser': 7.24.7 '@babel/types': 7.24.7 - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -13431,7 +13651,7 @@ snapshots: '@babel/helper-split-export-declaration': 7.24.7 '@babel/parser': 7.24.7 '@babel/types': 7.24.7 - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -13891,7 +14111,7 @@ snapshots: '@eslint/config-array@0.17.1': dependencies: '@eslint/object-schema': 2.1.4 - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -13899,7 +14119,7 @@ snapshots: '@eslint/eslintrc@3.1.0': dependencies: ajv: 6.12.6 - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) espree: 10.1.0 globals: 14.0.0 ignore: 5.3.1 @@ -14275,7 +14495,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.18 + '@jridgewell/trace-mapping': 0.3.25 '@types/node': 20.14.12 chalk: 4.1.2 collect-v8-coverage: 1.0.1 @@ -14303,7 +14523,7 @@ snapshots: '@jest/source-map@29.6.3': dependencies: - '@jridgewell/trace-mapping': 0.3.18 + '@jridgewell/trace-mapping': 0.3.25 callsites: 3.1.0 graceful-fs: 4.2.11 @@ -14325,7 +14545,7 @@ snapshots: dependencies: '@babel/core': 7.24.7 '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.18 + '@jridgewell/trace-mapping': 0.3.25 babel-plugin-istanbul: 6.1.1 chalk: 4.1.2 convert-source-map: 2.0.0 @@ -16219,12 +16439,12 @@ snapshots: '@storybook/global': 5.0.0 '@storybook/preview-api': 8.1.11 '@storybook/types': 8.1.11 - '@vue/compiler-core': 3.4.34 + '@vue/compiler-core': 3.4.37 lodash: 4.17.21 ts-dedent: 2.2.0 type-fest: 2.19.0 vue: 3.4.37(typescript@5.5.4) - vue-component-type-helpers: 2.0.29 + vue-component-type-helpers: 2.1.6 transitivePeerDependencies: - encoding - prettier @@ -16243,7 +16463,7 @@ snapshots: ts-dedent: 2.2.0 type-fest: 2.19.0 vue: 3.4.37(typescript@5.5.4) - vue-component-type-helpers: 2.0.29 + vue-component-type-helpers: 2.1.6 '@swc/cli@0.3.12(@swc/core@1.6.6)(chokidar@3.5.3)': dependencies: @@ -16527,7 +16747,7 @@ snapshots: '@testing-library/dom@9.3.4': dependencies: - '@babel/code-frame': 7.23.5 + '@babel/code-frame': 7.24.7 '@babel/runtime': 7.23.4 '@types/aria-query': 5.0.1 aria-query: 5.1.3 @@ -17065,7 +17285,7 @@ snapshots: '@typescript-eslint/types': 7.17.0 '@typescript-eslint/typescript-estree': 7.17.0(typescript@5.5.4) '@typescript-eslint/visitor-keys': 7.17.0 - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) eslint: 9.8.0 optionalDependencies: typescript: 5.5.4 @@ -17091,7 +17311,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 6.11.0(typescript@5.3.3) '@typescript-eslint/utils': 6.11.0(eslint@9.8.0)(typescript@5.3.3) - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) eslint: 9.8.0 ts-api-utils: 1.0.1(typescript@5.3.3) optionalDependencies: @@ -17103,7 +17323,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 7.1.0(typescript@5.3.3) '@typescript-eslint/utils': 7.1.0(eslint@9.8.0)(typescript@5.3.3) - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.5(supports-color@5.5.0) eslint: 9.8.0 ts-api-utils: 1.0.1(typescript@5.3.3) optionalDependencies: @@ -17115,7 +17335,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 7.17.0(typescript@5.5.4) '@typescript-eslint/utils': 7.17.0(eslint@9.8.0)(typescript@5.5.4) - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) eslint: 9.8.0 ts-api-utils: 1.3.0(typescript@5.5.4) optionalDependencies: @@ -17133,10 +17353,10 @@ snapshots: dependencies: '@typescript-eslint/types': 6.11.0 '@typescript-eslint/visitor-keys': 6.11.0 - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) globby: 11.1.0 is-glob: 4.0.3 - semver: 7.5.4 + semver: 7.6.0 ts-api-utils: 1.0.1(typescript@5.3.3) optionalDependencies: typescript: 5.3.3 @@ -17147,7 +17367,7 @@ snapshots: dependencies: '@typescript-eslint/types': 7.1.0 '@typescript-eslint/visitor-keys': 7.1.0 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.5(supports-color@5.5.0) globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.3 @@ -17162,7 +17382,7 @@ snapshots: dependencies: '@typescript-eslint/types': 7.17.0 '@typescript-eslint/visitor-keys': 7.17.0 - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.4 @@ -17182,7 +17402,7 @@ snapshots: '@typescript-eslint/types': 6.11.0 '@typescript-eslint/typescript-estree': 6.11.0(typescript@5.3.3) eslint: 9.8.0 - semver: 7.5.4 + semver: 7.6.0 transitivePeerDependencies: - supports-color - typescript @@ -17238,14 +17458,14 @@ snapshots: dependencies: '@ampproject/remapping': 2.2.1 '@bcoe/v8-coverage': 0.2.3 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.5(supports-color@5.5.0) istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-lib-source-maps: 5.0.4 istanbul-reports: 3.1.6 magic-string: 0.30.10 magicast: 0.3.4 - picocolors: 1.0.0 + picocolors: 1.0.1 std-env: 3.7.0 strip-literal: 2.1.0 test-exclude: 6.0.0 @@ -17253,6 +17473,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/coverage-v8@1.6.0(vitest@1.6.0(@types/node@20.14.12)(happy-dom@10.0.3)(jsdom@24.1.1)(sass@1.77.8)(terser@5.31.3))': + dependencies: + '@ampproject/remapping': 2.2.1 + '@bcoe/v8-coverage': 0.2.3 + debug: 4.3.5(supports-color@5.5.0) + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.4 + istanbul-reports: 3.1.6 + magic-string: 0.30.10 + magicast: 0.3.4 + picocolors: 1.0.1 + std-env: 3.7.0 + strip-literal: 2.1.0 + test-exclude: 6.0.0 + vitest: 1.6.0(@types/node@20.14.12)(happy-dom@10.0.3)(jsdom@24.1.1)(sass@1.77.8)(terser@5.31.3) + transitivePeerDependencies: + - supports-color + '@vitest/expect@1.6.0': dependencies: '@vitest/spy': 1.6.0 @@ -17315,14 +17554,6 @@ snapshots: estree-walker: 2.0.2 source-map-js: 1.2.0 - '@vue/compiler-core@3.4.34': - dependencies: - '@babel/parser': 7.24.7 - '@vue/shared': 3.4.34 - entities: 4.5.0 - estree-walker: 2.0.2 - source-map-js: 1.2.0 - '@vue/compiler-core@3.4.37': dependencies: '@babel/parser': 7.24.7 @@ -17331,11 +17562,6 @@ snapshots: estree-walker: 2.0.2 source-map-js: 1.2.0 - '@vue/compiler-dom@3.4.34': - dependencies: - '@vue/compiler-core': 3.4.34 - '@vue/shared': 3.4.34 - '@vue/compiler-dom@3.4.37': dependencies: '@vue/compiler-core': 3.4.37 @@ -17368,8 +17594,8 @@ snapshots: '@vue/language-core@2.0.16(typescript@5.5.4)': dependencies: '@volar/language-core': 2.2.0 - '@vue/compiler-dom': 3.4.34 - '@vue/shared': 3.4.34 + '@vue/compiler-dom': 3.4.37 + '@vue/shared': 3.4.37 computeds: 0.0.1 minimatch: 9.0.4 path-browserify: 1.0.1 @@ -17380,9 +17606,9 @@ snapshots: '@vue/language-core@2.0.29(typescript@5.5.4)': dependencies: '@volar/language-core': 2.4.0-alpha.18 - '@vue/compiler-dom': 3.4.34 + '@vue/compiler-dom': 3.4.37 '@vue/compiler-vue2': 2.7.16 - '@vue/shared': 3.4.34 + '@vue/shared': 3.4.37 computeds: 0.0.1 minimatch: 9.0.4 muggle-string: 0.4.1 @@ -17414,8 +17640,6 @@ snapshots: '@vue/shared@3.4.31': {} - '@vue/shared@3.4.34': {} - '@vue/shared@3.4.37': {} '@vue/test-utils@2.4.1(@vue/server-renderer@3.4.37(vue@3.4.37(typescript@5.5.4)))(vue@3.4.37(typescript@5.5.4))': @@ -17495,13 +17719,13 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) transitivePeerDependencies: - supports-color agent-base@7.1.0: dependencies: - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -17759,7 +17983,7 @@ snapshots: dependencies: '@fastify/error': 3.4.0 archy: 1.0.0 - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) fastq: 1.17.1 transitivePeerDependencies: - supports-color @@ -18076,7 +18300,7 @@ snapshots: http-cache-semantics: 4.1.1 keyv: 4.5.4 mimic-response: 4.0.0 - normalize-url: 8.0.0 + normalize-url: 8.0.1 responselike: 3.0.0 cacheable-request@12.0.1: @@ -18688,6 +18912,12 @@ snapshots: optionalDependencies: supports-color: 5.5.0 + debug@4.3.5(supports-color@5.5.0): + dependencies: + ms: 2.1.2 + optionalDependencies: + supports-color: 5.5.0 + debug@4.3.5(supports-color@8.1.1): dependencies: ms: 2.1.2 @@ -18807,7 +19037,7 @@ snapshots: detect-port@1.5.1: dependencies: address: 1.2.2 - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -19020,7 +19250,7 @@ snapshots: esbuild-register@3.5.0(esbuild@0.19.11): dependencies: - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) esbuild: 0.19.11 transitivePeerDependencies: - supports-color @@ -19250,7 +19480,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.3 - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) escape-string-regexp: 4.0.0 eslint-scope: 8.0.2 eslint-visitor-keys: 4.0.0 @@ -19290,10 +19520,6 @@ snapshots: esprima@4.0.1: {} - esquery@1.4.2: - dependencies: - estraverse: 5.3.0 - esquery@1.6.0: dependencies: estraverse: 5.3.0 @@ -19707,7 +19933,7 @@ snapshots: follow-redirects@1.15.2(debug@4.3.5): optionalDependencies: - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) for-each@0.3.3: dependencies: @@ -20167,7 +20393,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.0 - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -20206,28 +20432,28 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) transitivePeerDependencies: - supports-color https-proxy-agent@7.0.2: dependencies: agent-base: 7.1.0 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.5(supports-color@5.5.0) transitivePeerDependencies: - supports-color https-proxy-agent@7.0.4: dependencies: agent-base: 7.1.0 - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) transitivePeerDependencies: - supports-color https-proxy-agent@7.0.5: dependencies: agent-base: 7.1.0 - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -20575,7 +20801,7 @@ snapshots: istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -20584,7 +20810,7 @@ snapshots: istanbul-lib-source-maps@5.0.4: dependencies: '@jridgewell/trace-mapping': 0.3.25 - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color @@ -20884,7 +21110,7 @@ snapshots: jest-util: 29.7.0 natural-compare: 1.4.0 pretty-format: 29.7.0 - semver: 7.5.4 + semver: 7.6.0 transitivePeerDependencies: - supports-color @@ -21008,6 +21234,35 @@ snapshots: transitivePeerDependencies: - supports-color + jsdom@24.1.1: + dependencies: + cssstyle: 4.0.1 + data-urls: 5.0.0 + decimal.js: 10.4.3 + form-data: 4.0.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.5 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.12 + parse5: 7.1.2 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.4 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.0.0 + ws: 8.18.0(bufferutil@4.0.8)(utf-8-validate@6.0.4) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + optional: true + jsdom@24.1.1(bufferutil@4.0.7)(utf-8-validate@6.0.3): dependencies: cssstyle: 4.0.1 @@ -21693,7 +21948,7 @@ snapshots: micromark@4.0.0: dependencies: '@types/debug': 4.1.12 - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) decode-named-character-reference: 1.0.2 devlop: 1.1.0 micromark-core-commonmark: 2.0.0 @@ -22066,7 +22321,7 @@ snapshots: nodemon@3.1.4: dependencies: chokidar: 3.5.3 - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.5(supports-color@5.5.0) ignore-by-default: 1.0.1 minimatch: 3.1.2 pstree.remy: 1.1.8 @@ -22113,8 +22368,6 @@ snapshots: normalize-url@6.1.0: {} - normalize-url@8.0.0: {} - normalize-url@8.0.1: {} npm-run-path@2.0.2: @@ -22571,7 +22824,7 @@ snapshots: postcss-calc@9.0.1(postcss@8.4.40): dependencies: postcss: 8.4.40 - postcss-selector-parser: 6.0.15 + postcss-selector-parser: 6.0.16 postcss-value-parser: 4.2.0 postcss-colormin@6.1.0(postcss@8.4.40): @@ -22704,11 +22957,6 @@ snapshots: postcss: 8.4.40 postcss-value-parser: 4.2.0 - postcss-selector-parser@6.0.15: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - postcss-selector-parser@6.0.16: dependencies: cssesc: 3.0.0 @@ -22727,12 +22975,6 @@ snapshots: postcss-value-parser@4.2.0: {} - postcss@8.4.38: - dependencies: - nanoid: 3.3.7 - picocolors: 1.0.0 - source-map-js: 1.2.0 - postcss@8.4.40: dependencies: nanoid: 3.3.7 @@ -23263,7 +23505,7 @@ snapshots: require-in-the-middle@7.3.0: dependencies: - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) module-details-from-path: 1.0.3 resolve: 1.22.8 transitivePeerDependencies: @@ -23399,7 +23641,7 @@ snapshots: htmlparser2: 8.0.1 is-plain-object: 5.0.0 parse-srcset: 1.0.2 - postcss: 8.4.38 + postcss: 8.4.40 sass@1.77.8: dependencies: @@ -23544,7 +23786,7 @@ snapshots: dependencies: '@hapi/hoek': 11.0.4 '@hapi/wreck': 18.0.1 - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) joi: 17.11.0 transitivePeerDependencies: - supports-color @@ -23555,7 +23797,7 @@ snapshots: simple-update-notifier@2.0.0: dependencies: - semver: 7.5.4 + semver: 7.6.0 sinon@16.1.3: dependencies: @@ -23644,7 +23886,7 @@ snapshots: socks-proxy-agent@8.0.2: dependencies: agent-base: 7.1.0 - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) socks: 2.7.1 transitivePeerDependencies: - supports-color @@ -23739,7 +23981,7 @@ snapshots: arg: 5.0.2 bluebird: 3.7.2 check-more-types: 2.24.0 - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) execa: 5.1.1 lazy-ass: 1.6.0 ps-tree: 1.2.0 @@ -23968,7 +24210,7 @@ snapshots: css-tree: 2.3.1 css-what: 6.1.0 csso: 5.0.5 - picocolors: 1.0.0 + picocolors: 1.0.1 symbol-tree@3.2.4: {} @@ -24391,13 +24633,13 @@ snapshots: dependencies: browserslist: 4.22.2 escalade: 3.1.1 - picocolors: 1.0.0 + picocolors: 1.0.1 update-browserslist-db@1.0.13(browserslist@4.23.0): dependencies: browserslist: 4.23.0 escalade: 3.1.1 - picocolors: 1.0.0 + picocolors: 1.0.1 uri-js@4.4.1: dependencies: @@ -24449,7 +24691,7 @@ snapshots: v8-to-istanbul@9.2.0: dependencies: - '@jridgewell/trace-mapping': 0.3.18 + '@jridgewell/trace-mapping': 0.3.25 '@types/istanbul-lib-coverage': 2.0.4 convert-source-map: 2.0.0 @@ -24480,9 +24722,9 @@ snapshots: vite-node@1.6.0(@types/node@20.14.12)(sass@1.77.8)(terser@5.31.3): dependencies: cac: 6.7.14 - debug: 4.3.5(supports-color@8.1.1) + debug: 4.3.5(supports-color@5.5.0) pathe: 1.1.2 - picocolors: 1.0.0 + picocolors: 1.0.1 vite: 5.3.5(@types/node@20.14.12)(sass@1.77.8)(terser@5.31.3) transitivePeerDependencies: - '@types/node' @@ -24549,6 +24791,41 @@ snapshots: - supports-color - terser + vitest@1.6.0(@types/node@20.14.12)(happy-dom@10.0.3)(jsdom@24.1.1)(sass@1.77.8)(terser@5.31.3): + dependencies: + '@vitest/expect': 1.6.0 + '@vitest/runner': 1.6.0 + '@vitest/snapshot': 1.6.0 + '@vitest/spy': 1.6.0 + '@vitest/utils': 1.6.0 + acorn-walk: 8.3.2 + chai: 4.3.10 + debug: 4.3.4(supports-color@5.5.0) + execa: 8.0.1 + local-pkg: 0.5.0 + magic-string: 0.30.10 + pathe: 1.1.2 + picocolors: 1.0.0 + std-env: 3.7.0 + strip-literal: 2.1.0 + tinybench: 2.6.0 + tinypool: 0.8.4 + vite: 5.3.5(@types/node@20.14.12)(sass@1.77.8)(terser@5.31.3) + vite-node: 1.6.0(@types/node@20.14.12)(sass@1.77.8)(terser@5.31.3) + why-is-node-running: 2.2.2 + optionalDependencies: + '@types/node': 20.14.12 + happy-dom: 10.0.3 + jsdom: 24.1.1 + transitivePeerDependencies: + - less + - lightningcss + - sass + - stylus + - sugarss + - supports-color + - terser + void-elements@3.1.0: {} vscode-jsonrpc@8.2.0: {} @@ -24589,6 +24866,8 @@ snapshots: vue-component-type-helpers@2.0.29: {} + vue-component-type-helpers@2.1.6: {} + vue-demi@0.14.7(vue@3.4.37(typescript@5.5.4)): dependencies: vue: 3.4.37(typescript@5.5.4) @@ -24597,7 +24876,7 @@ snapshots: dependencies: '@babel/parser': 7.24.7 '@babel/types': 7.24.7 - '@vue/compiler-dom': 3.4.34 + '@vue/compiler-dom': 3.4.37 '@vue/compiler-sfc': 3.4.37 ast-types: 0.16.1 hash-sum: 2.0.0 @@ -24610,12 +24889,12 @@ snapshots: vue-eslint-parser@9.4.3(eslint@9.8.0): dependencies: - debug: 4.3.4(supports-color@5.5.0) + debug: 4.3.5(supports-color@5.5.0) eslint: 9.8.0 eslint-scope: 7.2.2 eslint-visitor-keys: 3.4.3 espree: 9.6.1 - esquery: 1.4.2 + esquery: 1.6.0 lodash: 4.17.21 semver: 7.6.0 transitivePeerDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 193669e7a40212208752349235b15fe5f358aa6e..d222614edaab5ee98b868244a11edaabaacb6d55 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,8 @@ packages: - 'packages/backend' + - 'packages/frontend-shared' - 'packages/frontend' + - 'packages/frontend-embed' - 'packages/sw' - 'packages/misskey-js' - 'packages/misskey-js/generator' diff --git a/scripts/build-assets.mjs b/scripts/build-assets.mjs index 2b275e12d68712d2a96bdf9fa099b6fc1eb93a7a..421d4a6d1bf2b2b82f73b42114959dc4d130eb0d 100644 --- a/scripts/build-assets.mjs +++ b/scripts/build-assets.mjs @@ -58,6 +58,7 @@ async function buildBackendScript() { for (const file of [ './packages/backend/src/server/web/boot.js', + './packages/backend/src/server/web/boot.embed.js', './packages/backend/src/server/web/bios.js', './packages/backend/src/server/web/cli.js' ]) { @@ -73,6 +74,7 @@ async function buildBackendStyle() { for (const file of [ './packages/backend/src/server/web/style.css', + './packages/backend/src/server/web/style.embed.css', './packages/backend/src/server/web/bios.css', './packages/backend/src/server/web/cli.css', './packages/backend/src/server/web/error.css' diff --git a/scripts/clean-all.js b/scripts/clean-all.js index e9512e2d5a5b6944fab53b550bee3384c2bb78b1..dc391ecfd8507b44dd9cda14be0e73262d06e9ca 100644 --- a/scripts/clean-all.js +++ b/scripts/clean-all.js @@ -10,9 +10,15 @@ const fs = require('fs'); fs.rmSync(__dirname + '/../packages/backend/built', { recursive: true, force: true }); fs.rmSync(__dirname + '/../packages/backend/node_modules', { recursive: true, force: true }); + fs.rmSync(__dirname + '/../packages/frontend-shared/built', { recursive: true, force: true }); + fs.rmSync(__dirname + '/../packages/frontend-shared/node_modules', { recursive: true, force: true }); + fs.rmSync(__dirname + '/../packages/frontend/built', { recursive: true, force: true }); fs.rmSync(__dirname + '/../packages/frontend/node_modules', { recursive: true, force: true }); + fs.rmSync(__dirname + '/../packages/frontend-embed/built', { recursive: true, force: true }); + fs.rmSync(__dirname + '/../packages/frontend-embed/node_modules', { recursive: true, force: true }); + fs.rmSync(__dirname + '/../packages/sw/built', { recursive: true, force: true }); fs.rmSync(__dirname + '/../packages/sw/node_modules', { recursive: true, force: true }); diff --git a/scripts/clean.js b/scripts/clean.js index af66c24a8f14186ed1c5ea0d8923c9c04cc63c64..86c19281ea264a09dc214464b35bc4ebad942729 100644 --- a/scripts/clean.js +++ b/scripts/clean.js @@ -7,7 +7,9 @@ const fs = require('fs'); (async () => { fs.rmSync(__dirname + '/../packages/backend/built', { recursive: true, force: true }); + fs.rmSync(__dirname + '/../packages/frontend-shared/built', { recursive: true, force: true }); fs.rmSync(__dirname + '/../packages/frontend/built', { recursive: true, force: true }); + fs.rmSync(__dirname + '/../packages/frontend-embed/built', { recursive: true, force: true }); fs.rmSync(__dirname + '/../packages/sw/built', { recursive: true, force: true }); fs.rmSync(__dirname + '/../packages/misskey-js/built', { recursive: true, force: true }); fs.rmSync(__dirname + '/../packages/misskey-reversi/built', { recursive: true, force: true }); diff --git a/scripts/dev.mjs b/scripts/dev.mjs index bbb2547758b1360e0867c724b67b4cbf381ad899..a4c82d46e1aa8141f92f57cb83add1771a897dee 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -65,12 +65,24 @@ execa('pnpm', ['--filter', 'backend', 'dev'], { stderr: process.stderr, }); +execa('pnpm', ['--filter', 'frontend-shared', 'watch'], { + cwd: _dirname + '/../', + stdout: process.stdout, + stderr: process.stderr, +}); + execa('pnpm', ['--filter', 'frontend', process.env.MK_DEV_PREFER === 'backend' ? 'watch' : 'dev'], { cwd: _dirname + '/../', stdout: process.stdout, stderr: process.stderr, }); +execa('pnpm', ['--filter', 'frontend-embed', process.env.MK_DEV_PREFER === 'backend' ? 'watch' : 'dev'], { + cwd: _dirname + '/../', + stdout: process.stdout, + stderr: process.stderr, +}); + execa('pnpm', ['--filter', 'sw', 'watch'], { cwd: _dirname + '/../', stdout: process.stdout,