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

* fix

* navhookをbootに移動

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

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

* tooltipは出せるように

* fix design

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

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

* fix indent

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

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

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

* Update MkEmbedCodeGenDialog.vue

* fix

* eliminate misskey-js lint warns

* fix

* add appropriate attributes to embed html

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

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

* type

* lint

* fix indent

* server-side styleを完全に分離

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

This reverts commit 05ca36f400.

* fix

* revert all changes in base.pug

* embedドメインをまとめた

* embedドメインをまとめた

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

* fix import

* fix import

* improve directory structure

* fix import

* register timeline ui as a container

* wa-

* rename

* wa-

* Update EmMediaList.vue

* Update EmMediaList.vue

* Update EmMediaList.vue

* Update EmMediaImage.vue

* Update EmNote.vue

* revert mkmedialist changes

* 戻し漏れ

* wip

* tweak embed media ui

* revert original media components

* Update boot.embed.js

* rename

* wip

* Update MkNote.vue

* wip

* Update MkSubNoteContent.vue

* Update EmNote.vue

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

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

This reverts commit 937ae44521.

* refactor EmMediaImage

* fix import

* remove unused imports

* Update router.ts

* wip

* Update boot.ts

* wip

* wip

* wip

* wip

* Update EmNote.vue

* Update EmNote.vue

* Create EmA.vue

* Create EmAvatar.vue

* Update EmAvatar.vue

* wip

* wip

* wip

* Create EmImgWithBlurhash.vue

* Update EmImgWithBlurhash.vue

* Create EmPagination.vue

* wip

* Update boot.ts

* wip

* wip

* wi@p

* wip

* wip

* wiop

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* Update boot.ts

* wip

* Update MkMisskeyFlavoredMarkdown.ts

* wip

* wip

* wip

* wip

* wip

* Update post-message.ts

* wip

* Update EmNoteDetailed.vue

* Update EmNoteDetailed.vue

* Create instance.ts

* Update EmNoteDetailed.vue

* wip

* Update EmNoteDetailed.vue

* wip

* wip

* wip

* Update pnpm-lock.yaml

* wip

* wip

* wp

* wip

* Update ClientServerService.ts

* wip

* Update boot.ts

* Update vite.config.local-dev.ts

* Update vite.config.ts

* Create index.html

* wa-

* wip

* Update boot.ts

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* Create EmLink.vue

* Create EmMention.vue

* Update EmMfm.ts

* wip

* wip

* wip

* wip

* Update vite.config.ts

* Update boot.ts

* Update EmA.vue

* うぃp

* wip

* wip

* Create EmError.vue

* wip

* Update MkEmbedCodeGenDialog.vue

* Update EmNote.vue

* wip

* wip

* Update user-timeline.vue

* Update check-spdx-license-id.yml

* wip

* wip

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

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

* wip

* use alias

* refactor

* refactor

* Update scroll.ts

* refactor

* refactor

* refactor

* wip

* wip

* wip

* wip

* Update roles.vue

* Update branding.vue

* wip

* wip

* wip

* Update page.vue

* wip

* fix import

* add missing css variables

* 絵文字をtwemojiに変更

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

* force empoll readonly

* fix compiler error

* fix broken imports

* tweak button style

* run api extractor

* fix storybook theme preloads

* fix storybook instance imports

* Update preview.ts

* Update preview.ts

* Update preview.ts

* Revert "Update preview.ts"

This reverts commit 12bab1c6fb.

* Revert "Update preview.ts"

This reverts commit 5c0ce01dbd.

* Revert "Update preview.ts"

This reverts commit f4863524d7.

* Revert "fix storybook instance imports"

This reverts commit ed8eabb246.

* Revert "wip"

This reverts commit d3c1926519.

* Revert "Update page.vue"

This reverts commit 27c7900b0c.

* Revert "Update branding.vue"

This reverts commit c08ccb65ba.

* Revert "Update roles.vue"

This reverts commit 1488b67066.

* Revert "wip"

This reverts commit aab1c76981.

* refactor: use common media proxy

* fix imports

* fix

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

* enhance(frontend-embed): improve embedParams provide

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

* fix

* embed-pageを共通化

* fix import

* fix import

* fix import

* const.jsを共通化

(たぶんrevertしすぎた)

* fix type error

* fix duplicated import

* fix lint

* fix

* コメントとして残す

* sharedとembedをlint対象にする

* lint

* attempt to fix eslint (frontend-shared)

* lint fixes

---------

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

View File

@ -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"

View File

@ -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:

View File

@ -4,6 +4,8 @@
-
### Client
- Feat: ノート単体・ユーザーのノート・クリップのノートの埋め込み機能
- 埋め込みコードやウェブサイトへの実装方法の詳細はMisskey Hubに掲載予定です
- サイズ制限を超過するファイルをアップロードしようとした際にエラーを出すように
- Enhance: アイコンデコレーション管理画面にプレビューを追加
- Fix: サーバーメトリクスが2つ以上あるとリロード直後の表示がおかしくなる問題を修正

View File

@ -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/"]

66
locales/index.d.ts vendored
View File

@ -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;

View File

@ -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: "生成されたコードをウェブサイトに貼り付けてご利用ください。"

View File

@ -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",

View File

@ -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';
}
});
});
})();

View File

@ -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),

View File

@ -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', {

View File

@ -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;
}`);
}
})();

View File

@ -3,17 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* BOOT LOADER
* サーバーからレスポンスされるHTMLに埋め込まれるスクリプトで以下の役割を持ちます
* - 翻訳ファイルをフェッチする
* - バージョンに基づいて適切なメインスクリプトを読み込む
* - キャッシュされたコンパイル済みテーマを適用する
* - クライアントの設定値に基づいて対応するHTMLクラス等を設定する
* テーマをこの段階で設定するのはメインスクリプトが読み込まれる間もテーマを適用したいためです
* : webpackは介さないためこのファイルではrequireやimportは使えません
*/
'use strict';
// ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので

View File

@ -47,6 +47,7 @@ html {
transform: translateY(70px);
color: var(--accent);
}
#splashSpinner > .spinner {
position: absolute;
top: 0;

View File

@ -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);
}
}

View File

@ -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

View File

@ -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

1
packages/frontend-embed/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/storybook-static

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@ -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'],
},
},
];

View File

@ -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"
}
}

View File

@ -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);
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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) {
// canvasmountedTmp
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(() => {
// drawImagemounted
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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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; // svgwebpublic/thumbnailpngtrue
// 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>

View File

@ -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>

View File

@ -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>

View File

@ -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));
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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;
});
};
/**
* AppearIntersectionObserverによって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);
// scrollToBottommoreFetching
// 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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 ''; // absoluterelative使
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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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';

View File

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

View File

@ -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>,
};

View File

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

View File

@ -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>

View File

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

View File

@ -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>

View File

@ -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>

View File

@ -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: APIHTML
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>

View File

@ -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>

View File

@ -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>

View File

@ -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,
}, '*');
}

View File

@ -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,
});

View File

@ -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%); }
}

View File

@ -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();
}

View File

@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as Misskey from 'misskey-js';
export function shouldCollapsed(note: Misskey.entities.Note, urls: string[]): boolean {
const collapsed = note.cw == null && (
note.text != null && (
(note.text.includes('$[x2')) ||
(note.text.includes('$[x3')) ||
(note.text.includes('$[x4')) ||
(note.text.includes('$[scale')) ||
(note.text.split('\n').length > 9) ||
(note.text.length > 500) ||
(urls.length >= 4)
) || note.files.length >= 5
);
return collapsed;
}

View File

@ -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;

View File

@ -0,0 +1,12 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export function isLink(el: HTMLElement) {
if (el.tagName === 'A') return true;
if (el.parentElement) {
return isLink(el.parentElement);
}
return false;
}

View File

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

View File

@ -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>

View File

@ -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}`;
};

View File

@ -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 });
};

View File

@ -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 });
}

View File

@ -0,0 +1,5 @@
{
"compilerOptions": {
"lib": ["esnext", "webworker"],
}
}

View File

@ -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/**/*"
]
}

View File

@ -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);

View File

@ -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;

View File

@ -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;
}
},
};
}

View File

@ -0,0 +1,6 @@
/* eslint-disable */
declare module "*.vue" {
import { defineComponent } from "vue";
const component: ReturnType<typeof defineComponent>;
export default component;
}

2
packages/frontend-shared/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/storybook-static
js-built

View File

@ -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.`);
});
}

View File

@ -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'],
},
},
];

View File

@ -0,0 +1,97 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
//#region Embed関連の定義
/** 埋め込みの対象となるエンティティ(/embed/xxx の xxx の部分と対応させる) */
const embeddableEntities = [
'notes',
'user-timeline',
'clips',
'tags',
] as const;
/** 埋め込みの対象となるエンティティ */
export type EmbeddableEntity = typeof embeddableEntities[number];
/** 内部でスクロールがあるページ */
export const embedRouteWithScrollbar: EmbeddableEntity[] = [
'clips',
'tags',
'user-timeline',
];
/** 埋め込みコードのパラメータ */
export type EmbedParams = {
maxHeight?: number;
colorMode?: 'light' | 'dark';
rounded?: boolean;
border?: boolean;
autoload?: boolean;
header?: boolean;
};
/** 正規化されたパラメータ */
export type ParsedEmbedParams = Required<Omit<EmbedParams, 'maxHeight' | 'colorMode'>> & Pick<EmbedParams, 'maxHeight' | 'colorMode'>;
/** パラメータのデフォルトの値 */
export const defaultEmbedParams = {
maxHeight: undefined,
colorMode: undefined,
rounded: true,
border: true,
autoload: false,
header: true,
} as const satisfies EmbedParams;
//#endregion
/**
*
* @param searchParams URLSearchParamsもしくはクエリ文字列
* @returns
*/
export function parseEmbedParams(searchParams: URLSearchParams | string): ParsedEmbedParams {
let _searchParams: URLSearchParams;
if (typeof searchParams === 'string') {
_searchParams = new URLSearchParams(searchParams);
} else if (searchParams instanceof URLSearchParams) {
_searchParams = searchParams;
} else {
throw new Error('searchParams must be URLSearchParams or string');
}
function convertBoolean(value: string | null): boolean | undefined {
if (value === 'true') {
return true;
} else if (value === 'false') {
return false;
}
return undefined;
}
function convertNumber(value: string | null): number | undefined {
if (value != null && !isNaN(Number(value))) {
return Number(value);
}
return undefined;
}
function convertColorMode(value: string | null): 'light' | 'dark' | undefined {
if (value != null && ['light', 'dark'].includes(value)) {
return value as 'light' | 'dark';
}
return undefined;
}
return {
maxHeight: convertNumber(_searchParams.get('maxHeight')) ?? defaultEmbedParams.maxHeight,
colorMode: convertColorMode(_searchParams.get('colorMode')) ?? defaultEmbedParams.colorMode,
rounded: convertBoolean(_searchParams.get('rounded')) ?? defaultEmbedParams.rounded,
border: convertBoolean(_searchParams.get('border')) ?? defaultEmbedParams.border,
autoload: convertBoolean(_searchParams.get('autoload')) ?? defaultEmbedParams.autoload,
header: convertBoolean(_searchParams.get('header')) ?? defaultEmbedParams.header,
};
}

View File

@ -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>(

View File

@ -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}`);
}

View File

@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as Misskey from 'misskey-js';
import { query } from './url.js';
export class MediaProxy {
private serverMetadata: Misskey.entities.MetaDetailed;
private url: string;
constructor(serverMetadata: Misskey.entities.MetaDetailed, url: string) {
this.serverMetadata = serverMetadata;
this.url = url;
}
public getProxiedImageUrl(imageUrl: string, type?: 'preview' | 'emoji' | 'avatar', mustOrigin = false, noFallback = false): string {
const localProxy = `${this.url}/proxy`;
let _imageUrl = imageUrl;
if (imageUrl.startsWith(this.serverMetadata.mediaProxy + '/') || imageUrl.startsWith('/proxy/') || imageUrl.startsWith(localProxy + '/')) {
// もう既にproxyっぽそうだったらurlを取り出す
_imageUrl = (new URL(imageUrl)).searchParams.get('url') ?? imageUrl;
}
return `${mustOrigin ? localProxy : this.serverMetadata.mediaProxy}/${
type === 'preview' ? 'preview.webp'
: 'image.webp'
}?${query({
url: _imageUrl,
...(!noFallback ? { 'fallback': '1' } : {}),
...(type ? { [type]: '1' } : {}),
...(mustOrigin ? { origin: '1' } : {}),
})}`;
}
public getProxiedImageUrlNullable(imageUrl: string | null | undefined, type?: 'preview'): string | null {
if (imageUrl == null) return null;
return this.getProxiedImageUrl(imageUrl, type);
}
public getStaticImageUrl(baseUrl: string): string {
const u = baseUrl.startsWith('http') ? new URL(baseUrl) : new URL(baseUrl, this.url);
if (u.href.startsWith(`${this.url}/emoji/`)) {
// もう既にemojiっぽそうだったらsearchParams付けるだけ
u.searchParams.set('static', '1');
return u.href;
}
if (u.href.startsWith(this.serverMetadata.mediaProxy + '/')) {
// もう既にproxyっぽそうだったらsearchParams付けるだけ
u.searchParams.set('static', '1');
return u.href;
}
return `${this.serverMetadata.mediaProxy}/static.webp?${query({
url: u.href,
static: '1',
})}`;
}
}

View File

@ -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();

View File

@ -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) {

View File

@ -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);

Some files were not shown because too many files have changed in this diff Show More