Merge branch 'develop' into patch-1
This commit is contained in:
commit
2c47f2eb4d
|
@ -0,0 +1,203 @@
|
|||
#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# Misskey configuration
|
||||
#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
# ┌─────┐
|
||||
#───┘ URL └─────────────────────────────────────────────────────
|
||||
|
||||
# Final accessible URL seen by a user.
|
||||
url: 'http://misskey.local'
|
||||
|
||||
# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
|
||||
# URL SETTINGS AFTER THAT!
|
||||
|
||||
# ┌───────────────────────┐
|
||||
#───┘ Port and TLS settings └───────────────────────────────────
|
||||
|
||||
#
|
||||
# Misskey requires a reverse proxy to support HTTPS connections.
|
||||
#
|
||||
# +----- https://example.tld/ ------------+
|
||||
# +------+ |+-------------+ +----------------+|
|
||||
# | User | ---> || Proxy (443) | ---> | Misskey (3000) ||
|
||||
# +------+ |+-------------+ +----------------+|
|
||||
# +---------------------------------------+
|
||||
#
|
||||
# You need to set up a reverse proxy. (e.g. nginx)
|
||||
# An encrypted connection with HTTPS is highly recommended
|
||||
# because tokens may be transferred in GET requests.
|
||||
|
||||
# The port that your Misskey server should listen on.
|
||||
port: 61812
|
||||
|
||||
# ┌──────────────────────────┐
|
||||
#───┘ PostgreSQL configuration └────────────────────────────────
|
||||
|
||||
db:
|
||||
host: db
|
||||
port: 5432
|
||||
|
||||
# Database name
|
||||
db: misskey
|
||||
|
||||
# Auth
|
||||
user: postgres
|
||||
pass: postgres
|
||||
|
||||
# Whether disable Caching queries
|
||||
#disableCache: true
|
||||
|
||||
# Extra Connection options
|
||||
#extra:
|
||||
# ssl: true
|
||||
|
||||
dbReplications: false
|
||||
|
||||
# You can configure any number of replicas here
|
||||
#dbSlaves:
|
||||
# -
|
||||
# host:
|
||||
# port:
|
||||
# db:
|
||||
# user:
|
||||
# pass:
|
||||
# -
|
||||
# host:
|
||||
# port:
|
||||
# db:
|
||||
# user:
|
||||
# pass:
|
||||
|
||||
# ┌─────────────────────┐
|
||||
#───┘ Redis configuration └─────────────────────────────────────
|
||||
|
||||
redis:
|
||||
host: redis
|
||||
port: 6379
|
||||
#family: 0 # 0=Both, 4=IPv4, 6=IPv6
|
||||
#pass: example-pass
|
||||
#prefix: example-prefix
|
||||
#db: 1
|
||||
|
||||
#redisForPubsub:
|
||||
# host: redis
|
||||
# port: 6379
|
||||
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
|
||||
# #pass: example-pass
|
||||
# #prefix: example-prefix
|
||||
# #db: 1
|
||||
|
||||
#redisForJobQueue:
|
||||
# host: redis
|
||||
# port: 6379
|
||||
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
|
||||
# #pass: example-pass
|
||||
# #prefix: example-prefix
|
||||
# #db: 1
|
||||
|
||||
#redisForTimelines:
|
||||
# host: redis
|
||||
# port: 6379
|
||||
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
|
||||
# #pass: example-pass
|
||||
# #prefix: example-prefix
|
||||
# #db: 1
|
||||
|
||||
# ┌───────────────────────────┐
|
||||
#───┘ MeiliSearch configuration └─────────────────────────────
|
||||
|
||||
#meilisearch:
|
||||
# host: meilisearch
|
||||
# port: 7700
|
||||
# apiKey: ''
|
||||
# ssl: true
|
||||
# index: ''
|
||||
|
||||
# ┌───────────────┐
|
||||
#───┘ ID generation └───────────────────────────────────────────
|
||||
|
||||
# You can select the ID generation method.
|
||||
# You don't usually need to change this setting, but you can
|
||||
# change it according to your preferences.
|
||||
|
||||
# Available methods:
|
||||
# aid ... Short, Millisecond accuracy
|
||||
# aidx ... Millisecond accuracy
|
||||
# meid ... Similar to ObjectID, Millisecond accuracy
|
||||
# ulid ... Millisecond accuracy
|
||||
# objectid ... This is left for backward compatibility
|
||||
|
||||
# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
|
||||
# ID SETTINGS AFTER THAT!
|
||||
|
||||
id: 'aidx'
|
||||
|
||||
# ┌────────────────┐
|
||||
#───┘ Error tracking └──────────────────────────────────────────
|
||||
|
||||
# Sentry is available for error tracking.
|
||||
# See the Sentry documentation for more details on options.
|
||||
|
||||
#sentryForBackend:
|
||||
# enableNodeProfiling: true
|
||||
# options:
|
||||
# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0'
|
||||
|
||||
#sentryForFrontend:
|
||||
# options:
|
||||
# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0'
|
||||
|
||||
# ┌─────────────────────┐
|
||||
#───┘ Other configuration └─────────────────────────────────────
|
||||
|
||||
# Whether disable HSTS
|
||||
#disableHsts: true
|
||||
|
||||
# Number of worker processes
|
||||
#clusterLimit: 1
|
||||
|
||||
# Job concurrency per worker
|
||||
# deliverJobConcurrency: 128
|
||||
# inboxJobConcurrency: 16
|
||||
|
||||
# Job rate limiter
|
||||
# deliverJobPerSec: 128
|
||||
# inboxJobPerSec: 32
|
||||
|
||||
# Job attempts
|
||||
# deliverJobMaxAttempts: 12
|
||||
# inboxJobMaxAttempts: 8
|
||||
|
||||
# IP address family used for outgoing request (ipv4, ipv6 or dual)
|
||||
#outgoingAddressFamily: ipv4
|
||||
|
||||
# Proxy for HTTP/HTTPS
|
||||
#proxy: http://127.0.0.1:3128
|
||||
|
||||
proxyBypassHosts:
|
||||
- api.deepl.com
|
||||
- api-free.deepl.com
|
||||
- www.recaptcha.net
|
||||
- hcaptcha.com
|
||||
- challenges.cloudflare.com
|
||||
|
||||
# Proxy for SMTP/SMTPS
|
||||
#proxySmtp: http://127.0.0.1:3128 # use HTTP/1.1 CONNECT
|
||||
#proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4
|
||||
#proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5
|
||||
|
||||
# Media Proxy
|
||||
#mediaProxy: https://example.com/proxy
|
||||
|
||||
# Proxy remote files (default: true)
|
||||
proxyRemoteFiles: true
|
||||
|
||||
# Sign to ActivityPub GET request (default: true)
|
||||
signToActivityPubGet: true
|
||||
|
||||
allowedPrivateNetworks: [
|
||||
'127.0.0.1/32'
|
||||
]
|
||||
|
||||
# Upload or download file size limits (bytes)
|
||||
#maxFileSize: 262144000
|
|
@ -3,6 +3,8 @@
|
|||
set -xe
|
||||
|
||||
sudo chown node node_modules
|
||||
sudo apt-get update
|
||||
sudo apt-get -y install libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2 libxtst6 xauth xvfb
|
||||
git config --global --add safe.directory /workspace
|
||||
git submodule update --init
|
||||
corepack install
|
||||
|
@ -12,3 +14,4 @@ pnpm install --frozen-lockfile
|
|||
cp .devcontainer/devcontainer.yml .config/default.yml
|
||||
pnpm build
|
||||
pnpm migrate
|
||||
pnpm exec cypress install
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
@ -40,15 +44,18 @@ jobs:
|
|||
needs: [pnpm_install]
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
env:
|
||||
eslint-cache-version: v1
|
||||
strategy:
|
||||
matrix:
|
||||
workspace:
|
||||
- backend
|
||||
- frontend
|
||||
- frontend-shared
|
||||
- frontend-embed
|
||||
- sw
|
||||
- misskey-js
|
||||
env:
|
||||
eslint-cache-version: v1
|
||||
eslint-cache-path: ${{ github.workspace }}/node_modules/.cache/eslint-${{ matrix.workspace }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4.1.1
|
||||
with:
|
||||
|
@ -64,11 +71,10 @@ jobs:
|
|||
- name: Restore eslint cache
|
||||
uses: actions/cache@v4.0.2
|
||||
with:
|
||||
path: node_modules/.cache/eslint
|
||||
key: eslint-${{ env.eslint-cache-version }}-${{ hashFiles('/pnpm-lock.yaml') }}-${{ github.ref_name }}-${{ github.sha }}
|
||||
restore-keys: |
|
||||
eslint-${{ env.eslint-cache-version }}-${{ hashFiles('/pnpm-lock.yaml') }}-
|
||||
- run: pnpm --filter ${{ matrix.workspace }} run eslint --cache --cache-location node_modules/.cache/eslint --cache-strategy content
|
||||
path: ${{ env.eslint-cache-path }}
|
||||
key: eslint-${{ env.eslint-cache-version }}-${{ matrix.workspace }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.ref_name }}-${{ github.sha }}
|
||||
restore-keys: eslint-${{ env.eslint-cache-version }}-${{ matrix.workspace }}-${{ hashFiles('**/pnpm-lock.yaml') }}-
|
||||
- run: pnpm --filter ${{ matrix.workspace }} run eslint --cache --cache-location ${{ env.eslint-cache-path }} --cache-strategy content
|
||||
|
||||
typecheck:
|
||||
needs: [pnpm_install]
|
||||
|
@ -78,6 +84,7 @@ jobs:
|
|||
matrix:
|
||||
workspace:
|
||||
- backend
|
||||
- sw
|
||||
- misskey-js
|
||||
steps:
|
||||
- uses: actions/checkout@v4.1.1
|
||||
|
@ -92,7 +99,7 @@ jobs:
|
|||
- run: corepack enable
|
||||
- run: pnpm i --frozen-lockfile
|
||||
- run: pnpm --filter misskey-js run build
|
||||
if: ${{ matrix.workspace == 'backend' }}
|
||||
if: ${{ matrix.workspace == 'backend' || matrix.workspace == 'sw' }}
|
||||
- run: pnpm --filter misskey-reversi run build
|
||||
if: ${{ matrix.workspace == 'backend' }}
|
||||
- run: pnpm --filter ${{ matrix.workspace }} run typecheck
|
||||
|
|
|
@ -35,6 +35,7 @@ coverage
|
|||
!/.config/example.yml
|
||||
!/.config/docker_example.yml
|
||||
!/.config/docker_example.env
|
||||
!/.config/cypress-devcontainer.yml
|
||||
docker-compose.yml
|
||||
compose.yml
|
||||
.devcontainer/compose.yml
|
||||
|
@ -44,6 +45,7 @@ compose.yml
|
|||
/build
|
||||
built
|
||||
built-test
|
||||
js-built
|
||||
/data
|
||||
/.cache-loader
|
||||
/db
|
||||
|
@ -63,6 +65,10 @@ temp
|
|||
tsdoc-metadata.json
|
||||
misskey-assets
|
||||
|
||||
# Vite temporary files
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# blender backups
|
||||
*.blend1
|
||||
*.blend2
|
||||
|
|
20
CHANGELOG.md
20
CHANGELOG.md
|
@ -1,13 +1,27 @@
|
|||
## Unreleased
|
||||
|
||||
### General
|
||||
-
|
||||
- Feat: UserWebhookとSystemWebhookのテスト送信機能を追加 (#14445)
|
||||
|
||||
### Client
|
||||
- サイズ制限を超過するファイルをアップロードしようとした際にエラーを出すように
|
||||
- Feat: ノート単体・ユーザーのノート・クリップのノートの埋め込み機能
|
||||
- 埋め込みコードやウェブサイトへの実装方法の詳細はMisskey Hubに掲載予定です
|
||||
- Enhance: サイズ制限を超過するファイルをアップロードしようとした際にエラーを出すように
|
||||
- Enhance: アイコンデコレーション管理画面にプレビューを追加
|
||||
- Enhance: コントロールパネル内のファイル一覧でセンシティブなファイルを区別しやすく
|
||||
- Enhance: ScratchpadにUIインスペクターを追加
|
||||
- Fix: サーバーメトリクスが2つ以上あるとリロード直後の表示がおかしくなる問題を修正
|
||||
- Fix: 月の違う同じ日はセパレータが表示されないのを修正
|
||||
- Fix: 縦横比が極端なカスタム絵文字を表示する際にレイアウトが崩れる箇所があるのを修正
|
||||
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/725)
|
||||
- Fix: 設定変更時のリロード確認ダイアログが複数個表示されることがある問題を修正
|
||||
|
||||
### Server
|
||||
- ファイルがサイズの制限を超えてアップロードされた際にエラーを返さなかった問題を修正
|
||||
- Fix: アンテナの書き込み時にキーワードが与えられなかった場合のエラーをApiErrorとして投げるように
|
||||
- この変更により、公式フロントエンドでは入力の不備が内部エラーとして報告される代わりに一般的なエラーダイアログで報告されます
|
||||
- Fix: ファイルがサイズの制限を超えてアップロードされた際にエラーを返さなかった問題を修正
|
||||
- Fix: 外部ページを解析する際に、ページに紐づけられた関連リソースも読み込まれてしまう問題を修正
|
||||
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/commit/26e0412fbb91447c37e8fb06ffb0487346063bb8)
|
||||
- Fix: Continue importing from file if single emoji import fails
|
||||
|
||||
## 2024.8.0
|
||||
|
|
|
@ -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/"]
|
||||
|
|
|
@ -3121,7 +3121,7 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"narrow": string;
|
||||
/**
|
||||
* 設定はページリロード後に反映されます。今すぐリロードしますか?
|
||||
* 設定はページリロード後に反映されます。
|
||||
*/
|
||||
"reloadToApplySetting": string;
|
||||
/**
|
||||
|
@ -5068,6 +5068,18 @@ export interface Locale extends ILocale {
|
|||
* 作成したアンテナ
|
||||
*/
|
||||
"createdAntennas": string;
|
||||
/**
|
||||
* {x}から
|
||||
*/
|
||||
"fromX": ParameterizedString<"x">;
|
||||
/**
|
||||
* 埋め込みコードを生成
|
||||
*/
|
||||
"genEmbedCode": string;
|
||||
/**
|
||||
* このユーザーのノート一覧
|
||||
*/
|
||||
"noteOfThisUser": string;
|
||||
/**
|
||||
* これ以上このクリップにノートを追加できません。
|
||||
*/
|
||||
|
@ -9465,6 +9477,10 @@ export interface Locale extends ILocale {
|
|||
* Webhookを削除しますか?
|
||||
*/
|
||||
"deleteConfirm": string;
|
||||
/**
|
||||
* スイッチの右にあるボタンをクリックするとダミーのデータを使用したテスト用Webhookを送信できます。
|
||||
*/
|
||||
"testRemarks": string;
|
||||
};
|
||||
"_abuseReport": {
|
||||
"_notificationRecipient": {
|
||||
|
@ -10196,6 +10212,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;
|
||||
|
|
|
@ -592,6 +592,8 @@ ascendingOrder: "昇順"
|
|||
descendingOrder: "降順"
|
||||
scratchpad: "スクラッチパッド"
|
||||
scratchpadDescription: "スクラッチパッドは、AiScriptの実験環境を提供します。Misskeyと対話するコードの記述、実行、結果の確認ができます。"
|
||||
uiInspector: "UIインスペクター"
|
||||
uiInspectorDescription: "メモリ上に存在しているUIコンポーネントのインスタンスの一覧を見ることができます。UIコンポーネントはUi:C:系関数により生成されます。"
|
||||
output: "出力"
|
||||
script: "スクリプト"
|
||||
disablePagesScript: "Pagesのスクリプトを無効にする"
|
||||
|
@ -776,7 +778,7 @@ left: "左"
|
|||
center: "中央"
|
||||
wide: "広い"
|
||||
narrow: "狭い"
|
||||
reloadToApplySetting: "設定はページリロード後に反映されます。今すぐリロードしますか?"
|
||||
reloadToApplySetting: "設定はページリロード後に反映されます。"
|
||||
needReloadToApply: "反映には再起動が必要です。"
|
||||
showTitlebar: "タイトルバーを表示する"
|
||||
clearCache: "キャッシュをクリア"
|
||||
|
@ -1263,6 +1265,9 @@ confirmWhenRevealingSensitiveMedia: "センシティブなメディアを表示
|
|||
sensitiveMediaRevealConfirm: "センシティブなメディアです。表示しますか?"
|
||||
createdLists: "作成したリスト"
|
||||
createdAntennas: "作成したアンテナ"
|
||||
fromX: "{x}から"
|
||||
genEmbedCode: "埋め込みコードを生成"
|
||||
noteOfThisUser: "このユーザーのノート一覧"
|
||||
clipNoteLimitExceeded: "これ以上このクリップにノートを追加できません。"
|
||||
|
||||
_delivery:
|
||||
|
@ -2509,6 +2514,7 @@ _webhookSettings:
|
|||
abuseReportResolved: "ユーザーからの通報を処理したとき"
|
||||
userCreated: "ユーザーが作成されたとき"
|
||||
deleteConfirm: "Webhookを削除しますか?"
|
||||
testRemarks: "スイッチの右にあるボタンをクリックするとダミーのデータを使用したテスト用Webhookを送信できます。"
|
||||
|
||||
_abuseReport:
|
||||
_notificationRecipient:
|
||||
|
@ -2718,3 +2724,18 @@ _contextMenu:
|
|||
app: "アプリケーション"
|
||||
appWithShift: "Shiftキーでアプリケーション"
|
||||
native: "ブラウザのUI"
|
||||
|
||||
_embedCodeGen:
|
||||
title: "埋め込みコードをカスタマイズ"
|
||||
header: "ヘッダーを表示"
|
||||
autoload: "自動で続きを読み込む(非推奨)"
|
||||
maxHeight: "高さの最大値"
|
||||
maxHeightDescription: "0で最大値の設定が無効になります。ウィジェットが縦に伸び続けるのを防ぐために、何らかの値に指定してください。"
|
||||
maxHeightWarn: "高さの最大値制限が無効(0)になっています。これが意図した変更ではない場合は、高さの最大値を何らかの値に設定してください。"
|
||||
previewIsNotActual: "プレビュー画面で表示可能な範囲を超えたため、実際に埋め込んだ際とは表示が異なります。"
|
||||
rounded: "角丸にする"
|
||||
border: "外枠に枠線をつける"
|
||||
applyToPreview: "プレビューに反映"
|
||||
generateCode: "埋め込みコードを作成"
|
||||
codeGenerated: "コードが生成されました"
|
||||
codeGeneratedDescription: "生成されたコードをウェブサイトに貼り付けてご利用ください。"
|
||||
|
|
|
@ -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",
|
||||
|
@ -35,6 +37,7 @@
|
|||
"cy:open": "pnpm cypress open --browser --e2e --config-file=cypress.config.ts",
|
||||
"cy:run": "pnpm cypress run",
|
||||
"e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run",
|
||||
"e2e-dev-container": "cp ./.config/cypress-devcontainer.yml ./.config/test.yml && pnpm start-server-and-test start:test http://localhost:61812 cy:run",
|
||||
"jest": "cd packages/backend && pnpm jest",
|
||||
"jest-and-coverage": "cd packages/backend && pnpm jest-and-coverage",
|
||||
"test": "pnpm -r test",
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
|
@ -100,7 +100,7 @@
|
|||
"async-mutex": "0.5.0",
|
||||
"bcryptjs": "2.4.3",
|
||||
"blurhash": "2.0.5",
|
||||
"body-parser": "1.20.2",
|
||||
"body-parser": "1.20.3",
|
||||
"bullmq": "5.10.4",
|
||||
"cacheable-lookup": "7.0.0",
|
||||
"cbor": "9.0.2",
|
||||
|
@ -119,7 +119,7 @@
|
|||
"fluent-ffmpeg": "2.1.3",
|
||||
"form-data": "4.0.0",
|
||||
"got": "14.4.2",
|
||||
"happy-dom": "10.0.3",
|
||||
"happy-dom": "15.6.1",
|
||||
"hpagent": "1.2.0",
|
||||
"htmlescape": "1.1.1",
|
||||
"http-link-header": "1.1.3",
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -123,11 +123,14 @@ export class AntennaService implements OnApplicationShutdown {
|
|||
if (antenna.src === 'home') {
|
||||
// TODO
|
||||
} else if (antenna.src === 'list') {
|
||||
const listUsers = (await this.userListMembershipsRepository.findBy({
|
||||
userListId: antenna.userListId!,
|
||||
})).map(x => x.userId);
|
||||
|
||||
if (!listUsers.includes(note.userId)) return false;
|
||||
if (antenna.userListId == null) return false;
|
||||
const exists = await this.userListMembershipsRepository.exists({
|
||||
where: {
|
||||
userListId: antenna.userListId,
|
||||
userId: note.userId,
|
||||
},
|
||||
});
|
||||
if (!exists) return false;
|
||||
} else if (antenna.src === 'users') {
|
||||
const accts = antenna.users.map(x => {
|
||||
const { username, host } = Acct.parse(x);
|
||||
|
|
|
@ -13,6 +13,7 @@ import {
|
|||
import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js';
|
||||
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
|
||||
import { UserSearchService } from '@/core/UserSearchService.js';
|
||||
import { WebhookTestService } from '@/core/WebhookTestService.js';
|
||||
import { AccountMoveService } from './AccountMoveService.js';
|
||||
import { AccountUpdateService } from './AccountUpdateService.js';
|
||||
import { AiService } from './AiService.js';
|
||||
|
@ -211,6 +212,7 @@ const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: Us
|
|||
const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService };
|
||||
const $UserWebhookService: Provider = { provide: 'UserWebhookService', useExisting: UserWebhookService };
|
||||
const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useExisting: SystemWebhookService };
|
||||
const $WebhookTestService: Provider = { provide: 'WebhookTestService', useExisting: WebhookTestService };
|
||||
const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService };
|
||||
const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService };
|
||||
const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService };
|
||||
|
@ -359,6 +361,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
VideoProcessingService,
|
||||
UserWebhookService,
|
||||
SystemWebhookService,
|
||||
WebhookTestService,
|
||||
UtilityService,
|
||||
FileInfoService,
|
||||
SearchService,
|
||||
|
@ -503,6 +506,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$VideoProcessingService,
|
||||
$UserWebhookService,
|
||||
$SystemWebhookService,
|
||||
$WebhookTestService,
|
||||
$UtilityService,
|
||||
$FileInfoService,
|
||||
$SearchService,
|
||||
|
@ -648,6 +652,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
VideoProcessingService,
|
||||
UserWebhookService,
|
||||
SystemWebhookService,
|
||||
WebhookTestService,
|
||||
UtilityService,
|
||||
FileInfoService,
|
||||
SearchService,
|
||||
|
@ -791,6 +796,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$VideoProcessingService,
|
||||
$UserWebhookService,
|
||||
$SystemWebhookService,
|
||||
$WebhookTestService,
|
||||
$UtilityService,
|
||||
$FileInfoService,
|
||||
$SearchService,
|
||||
|
|
|
@ -452,10 +452,15 @@ export class QueueService {
|
|||
|
||||
/**
|
||||
* @see UserWebhookDeliverJobData
|
||||
* @see WebhookDeliverProcessorService
|
||||
* @see UserWebhookDeliverProcessorService
|
||||
*/
|
||||
@bindThis
|
||||
public userWebhookDeliver(webhook: MiWebhook, type: typeof webhookEventTypes[number], content: unknown) {
|
||||
public userWebhookDeliver(
|
||||
webhook: MiWebhook,
|
||||
type: typeof webhookEventTypes[number],
|
||||
content: unknown,
|
||||
opts?: { attempts?: number },
|
||||
) {
|
||||
const data: UserWebhookDeliverJobData = {
|
||||
type,
|
||||
content,
|
||||
|
@ -468,7 +473,7 @@ export class QueueService {
|
|||
};
|
||||
|
||||
return this.userWebhookDeliverQueue.add(webhook.id, data, {
|
||||
attempts: 4,
|
||||
attempts: opts?.attempts ?? 4,
|
||||
backoff: {
|
||||
type: 'custom',
|
||||
},
|
||||
|
@ -479,10 +484,15 @@ export class QueueService {
|
|||
|
||||
/**
|
||||
* @see SystemWebhookDeliverJobData
|
||||
* @see WebhookDeliverProcessorService
|
||||
* @see SystemWebhookDeliverProcessorService
|
||||
*/
|
||||
@bindThis
|
||||
public systemWebhookDeliver(webhook: MiSystemWebhook, type: SystemWebhookEventType, content: unknown) {
|
||||
public systemWebhookDeliver(
|
||||
webhook: MiSystemWebhook,
|
||||
type: SystemWebhookEventType,
|
||||
content: unknown,
|
||||
opts?: { attempts?: number },
|
||||
) {
|
||||
const data: SystemWebhookDeliverJobData = {
|
||||
type,
|
||||
content,
|
||||
|
@ -494,7 +504,7 @@ export class QueueService {
|
|||
};
|
||||
|
||||
return this.systemWebhookDeliverQueue.add(webhook.id, data, {
|
||||
attempts: 4,
|
||||
attempts: opts?.attempts ?? 4,
|
||||
backoff: {
|
||||
type: 'custom',
|
||||
},
|
||||
|
|
|
@ -54,7 +54,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
|
|||
* SystemWebhook の一覧を取得する.
|
||||
*/
|
||||
@bindThis
|
||||
public async fetchSystemWebhooks(params?: {
|
||||
public fetchSystemWebhooks(params?: {
|
||||
ids?: MiSystemWebhook['id'][];
|
||||
isActive?: MiSystemWebhook['isActive'];
|
||||
on?: MiSystemWebhook['on'];
|
||||
|
@ -165,19 +165,24 @@ export class SystemWebhookService implements OnApplicationShutdown {
|
|||
/**
|
||||
* SystemWebhook をWebhook配送キューに追加する
|
||||
* @see QueueService.systemWebhookDeliver
|
||||
* // TODO: contentの型を厳格化する
|
||||
*/
|
||||
@bindThis
|
||||
public async enqueueSystemWebhook(webhook: MiSystemWebhook | MiSystemWebhook['id'], type: SystemWebhookEventType, content: unknown) {
|
||||
public async enqueueSystemWebhook<T extends SystemWebhookEventType>(
|
||||
webhook: MiSystemWebhook | MiSystemWebhook['id'],
|
||||
type: T,
|
||||
content: unknown,
|
||||
) {
|
||||
const webhookEntity = typeof webhook === 'string'
|
||||
? (await this.fetchActiveSystemWebhooks()).find(a => a.id === webhook)
|
||||
: webhook;
|
||||
if (!webhookEntity || !webhookEntity.isActive) {
|
||||
this.logger.info(`Webhook is not active or not found : ${webhook}`);
|
||||
this.logger.info(`SystemWebhook is not active or not found : ${webhook}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!webhookEntity.on.includes(type)) {
|
||||
this.logger.info(`Webhook ${webhookEntity.id} is not listening to ${type}`);
|
||||
this.logger.info(`SystemWebhook ${webhookEntity.id} is not listening to ${type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -5,8 +5,8 @@
|
|||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import type { WebhooksRepository } from '@/models/_.js';
|
||||
import type { MiWebhook } from '@/models/Webhook.js';
|
||||
import { type WebhooksRepository } from '@/models/_.js';
|
||||
import { MiWebhook } from '@/models/Webhook.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
|
@ -38,6 +38,31 @@ export class UserWebhookService implements OnApplicationShutdown {
|
|||
return this.activeWebhooks;
|
||||
}
|
||||
|
||||
/**
|
||||
* UserWebhook の一覧を取得する.
|
||||
*/
|
||||
@bindThis
|
||||
public fetchWebhooks(params?: {
|
||||
ids?: MiWebhook['id'][];
|
||||
isActive?: MiWebhook['active'];
|
||||
on?: MiWebhook['on'];
|
||||
}): Promise<MiWebhook[]> {
|
||||
const query = this.webhooksRepository.createQueryBuilder('webhook');
|
||||
if (params) {
|
||||
if (params.ids && params.ids.length > 0) {
|
||||
query.andWhere('webhook.id IN (:...ids)', { ids: params.ids });
|
||||
}
|
||||
if (params.isActive !== undefined) {
|
||||
query.andWhere('webhook.active = :isActive', { isActive: params.isActive });
|
||||
}
|
||||
if (params.on && params.on.length > 0) {
|
||||
query.andWhere(':on <@ webhook.on', { on: params.on });
|
||||
}
|
||||
}
|
||||
|
||||
return query.getMany();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async onMessage(_: string, data: string): Promise<void> {
|
||||
const obj = JSON.parse(data);
|
||||
|
|
|
@ -0,0 +1,434 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { MiAbuseUserReport, MiNote, MiUser, MiWebhook } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { MiSystemWebhook, type SystemWebhookEventType } from '@/models/SystemWebhook.js';
|
||||
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
|
||||
import { Packed } from '@/misc/json-schema.js';
|
||||
import { type WebhookEventTypes } from '@/models/Webhook.js';
|
||||
import { UserWebhookService } from '@/core/UserWebhookService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
|
||||
const oneDayMillis = 24 * 60 * 60 * 1000;
|
||||
|
||||
function generateAbuseReport(override?: Partial<MiAbuseUserReport>): MiAbuseUserReport {
|
||||
return {
|
||||
id: 'dummy-abuse-report1',
|
||||
targetUserId: 'dummy-target-user',
|
||||
targetUser: null,
|
||||
reporterId: 'dummy-reporter-user',
|
||||
reporter: null,
|
||||
assigneeId: null,
|
||||
assignee: null,
|
||||
resolved: false,
|
||||
forwarded: false,
|
||||
comment: 'This is a dummy report for testing purposes.',
|
||||
targetUserHost: null,
|
||||
reporterHost: null,
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
||||
function generateDummyUser(override?: Partial<MiUser>): MiUser {
|
||||
return {
|
||||
id: 'dummy-user-1',
|
||||
updatedAt: new Date(Date.now() - oneDayMillis * 7),
|
||||
lastFetchedAt: new Date(Date.now() - oneDayMillis * 5),
|
||||
lastActiveDate: new Date(Date.now() - oneDayMillis * 3),
|
||||
hideOnlineStatus: false,
|
||||
username: 'dummy1',
|
||||
usernameLower: 'dummy1',
|
||||
name: 'DummyUser1',
|
||||
followersCount: 10,
|
||||
followingCount: 5,
|
||||
movedToUri: null,
|
||||
movedAt: null,
|
||||
alsoKnownAs: null,
|
||||
notesCount: 30,
|
||||
avatarId: null,
|
||||
avatar: null,
|
||||
bannerId: null,
|
||||
banner: null,
|
||||
avatarUrl: null,
|
||||
bannerUrl: null,
|
||||
avatarBlurhash: null,
|
||||
bannerBlurhash: null,
|
||||
avatarDecorations: [],
|
||||
tags: [],
|
||||
isSuspended: false,
|
||||
isLocked: false,
|
||||
isBot: false,
|
||||
isCat: true,
|
||||
isRoot: false,
|
||||
isExplorable: true,
|
||||
isHibernated: false,
|
||||
isDeleted: false,
|
||||
emojis: [],
|
||||
host: null,
|
||||
inbox: null,
|
||||
sharedInbox: null,
|
||||
featured: null,
|
||||
uri: null,
|
||||
followersUri: null,
|
||||
token: null,
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
||||
function generateDummyNote(override?: Partial<MiNote>): MiNote {
|
||||
return {
|
||||
id: 'dummy-note-1',
|
||||
replyId: null,
|
||||
reply: null,
|
||||
renoteId: null,
|
||||
renote: null,
|
||||
threadId: null,
|
||||
text: 'This is a dummy note for testing purposes.',
|
||||
name: null,
|
||||
cw: null,
|
||||
userId: 'dummy-user-1',
|
||||
user: null,
|
||||
localOnly: true,
|
||||
reactionAcceptance: 'likeOnly',
|
||||
renoteCount: 10,
|
||||
repliesCount: 5,
|
||||
clippedCount: 0,
|
||||
reactions: {},
|
||||
visibility: 'public',
|
||||
uri: null,
|
||||
url: null,
|
||||
fileIds: [],
|
||||
attachedFileTypes: [],
|
||||
visibleUserIds: [],
|
||||
mentions: [],
|
||||
mentionedRemoteUsers: '[]',
|
||||
reactionAndUserPairCache: [],
|
||||
emojis: [],
|
||||
tags: [],
|
||||
hasPoll: false,
|
||||
channelId: null,
|
||||
channel: null,
|
||||
userHost: null,
|
||||
replyUserId: null,
|
||||
replyUserHost: null,
|
||||
renoteUserId: null,
|
||||
renoteUserHost: null,
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
||||
function toPackedNote(note: MiNote, detail = true, override?: Packed<'Note'>): Packed<'Note'> {
|
||||
return {
|
||||
id: note.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
deletedAt: null,
|
||||
text: note.text,
|
||||
cw: note.cw,
|
||||
userId: note.userId,
|
||||
user: toPackedUserLite(note.user ?? generateDummyUser()),
|
||||
replyId: note.replyId,
|
||||
renoteId: note.renoteId,
|
||||
isHidden: false,
|
||||
visibility: note.visibility,
|
||||
mentions: note.mentions,
|
||||
visibleUserIds: note.visibleUserIds,
|
||||
fileIds: note.fileIds,
|
||||
files: [],
|
||||
tags: note.tags,
|
||||
poll: null,
|
||||
emojis: note.emojis,
|
||||
channelId: note.channelId,
|
||||
channel: note.channel,
|
||||
localOnly: note.localOnly,
|
||||
reactionAcceptance: note.reactionAcceptance,
|
||||
reactionEmojis: {},
|
||||
reactions: {},
|
||||
reactionCount: 0,
|
||||
renoteCount: note.renoteCount,
|
||||
repliesCount: note.repliesCount,
|
||||
uri: note.uri ?? undefined,
|
||||
url: note.url ?? undefined,
|
||||
reactionAndUserPairCache: note.reactionAndUserPairCache,
|
||||
...(detail ? {
|
||||
clippedCount: note.clippedCount,
|
||||
reply: note.reply ? toPackedNote(note.reply, false) : null,
|
||||
renote: note.renote ? toPackedNote(note.renote, true) : null,
|
||||
myReaction: null,
|
||||
} : {}),
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
||||
function toPackedUserLite(user: MiUser, override?: Packed<'UserLite'>): Packed<'UserLite'> {
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
host: user.host,
|
||||
avatarUrl: user.avatarUrl,
|
||||
avatarBlurhash: user.avatarBlurhash,
|
||||
avatarDecorations: user.avatarDecorations.map(it => ({
|
||||
id: it.id,
|
||||
angle: it.angle,
|
||||
flipH: it.flipH,
|
||||
url: 'https://example.com/dummy-image001.png',
|
||||
offsetX: it.offsetX,
|
||||
offsetY: it.offsetY,
|
||||
})),
|
||||
isBot: user.isBot,
|
||||
isCat: user.isCat,
|
||||
emojis: user.emojis,
|
||||
onlineStatus: 'active',
|
||||
badgeRoles: [],
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
||||
function toPackedUserDetailedNotMe(user: MiUser, override?: Packed<'UserDetailedNotMe'>): Packed<'UserDetailedNotMe'> {
|
||||
return {
|
||||
...toPackedUserLite(user),
|
||||
url: null,
|
||||
uri: null,
|
||||
movedTo: null,
|
||||
alsoKnownAs: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: user.updatedAt?.toISOString() ?? null,
|
||||
lastFetchedAt: user.lastFetchedAt?.toISOString() ?? null,
|
||||
bannerUrl: user.bannerUrl,
|
||||
bannerBlurhash: user.bannerBlurhash,
|
||||
isLocked: user.isLocked,
|
||||
isSilenced: false,
|
||||
isSuspended: user.isSuspended,
|
||||
description: null,
|
||||
location: null,
|
||||
birthday: null,
|
||||
lang: null,
|
||||
fields: [],
|
||||
verifiedLinks: [],
|
||||
followersCount: user.followersCount,
|
||||
followingCount: user.followingCount,
|
||||
notesCount: user.notesCount,
|
||||
pinnedNoteIds: [],
|
||||
pinnedNotes: [],
|
||||
pinnedPageId: null,
|
||||
pinnedPage: null,
|
||||
publicReactions: true,
|
||||
followersVisibility: 'public',
|
||||
followingVisibility: 'public',
|
||||
twoFactorEnabled: false,
|
||||
usePasswordLessLogin: false,
|
||||
securityKeys: false,
|
||||
roles: [],
|
||||
memo: null,
|
||||
moderationNote: undefined,
|
||||
isFollowing: false,
|
||||
isFollowed: false,
|
||||
hasPendingFollowRequestFromYou: false,
|
||||
hasPendingFollowRequestToYou: false,
|
||||
isBlocking: false,
|
||||
isBlocked: false,
|
||||
isMuted: false,
|
||||
isRenoteMuted: false,
|
||||
notify: 'none',
|
||||
withReplies: true,
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
||||
const dummyUser1 = generateDummyUser();
|
||||
const dummyUser2 = generateDummyUser({
|
||||
id: 'dummy-user-2',
|
||||
updatedAt: new Date(Date.now() - oneDayMillis * 30),
|
||||
lastFetchedAt: new Date(Date.now() - oneDayMillis),
|
||||
lastActiveDate: new Date(Date.now() - oneDayMillis),
|
||||
username: 'dummy2',
|
||||
usernameLower: 'dummy2',
|
||||
name: 'DummyUser2',
|
||||
followersCount: 40,
|
||||
followingCount: 50,
|
||||
notesCount: 900,
|
||||
});
|
||||
const dummyUser3 = generateDummyUser({
|
||||
id: 'dummy-user-3',
|
||||
updatedAt: new Date(Date.now() - oneDayMillis * 15),
|
||||
lastFetchedAt: new Date(Date.now() - oneDayMillis * 2),
|
||||
lastActiveDate: new Date(Date.now() - oneDayMillis * 2),
|
||||
username: 'dummy3',
|
||||
usernameLower: 'dummy3',
|
||||
name: 'DummyUser3',
|
||||
followersCount: 60,
|
||||
followingCount: 70,
|
||||
notesCount: 15900,
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class WebhookTestService {
|
||||
public static NoSuchWebhookError = class extends Error {};
|
||||
|
||||
constructor(
|
||||
private userWebhookService: UserWebhookService,
|
||||
private systemWebhookService: SystemWebhookService,
|
||||
private queueService: QueueService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* UserWebhookのテスト送信を行う.
|
||||
* 送信されるペイロードはいずれもダミーの値で、実際にはデータベース上に存在しない.
|
||||
*
|
||||
* また、この関数経由で送信されるWebhookは以下の設定を無視する.
|
||||
* - Webhookそのものの有効・無効設定(active)
|
||||
* - 送信対象イベント(on)に関する設定
|
||||
*/
|
||||
@bindThis
|
||||
public async testUserWebhook(
|
||||
params: {
|
||||
webhookId: MiWebhook['id'],
|
||||
type: WebhookEventTypes,
|
||||
override?: Partial<Omit<MiWebhook, 'id'>>,
|
||||
},
|
||||
sender: MiUser | null,
|
||||
) {
|
||||
const webhooks = await this.userWebhookService.fetchWebhooks({ ids: [params.webhookId] })
|
||||
.then(it => it.filter(it => it.userId === sender?.id));
|
||||
if (webhooks.length === 0) {
|
||||
throw new WebhookTestService.NoSuchWebhookError();
|
||||
}
|
||||
|
||||
const webhook = webhooks[0];
|
||||
const send = (contents: unknown) => {
|
||||
const merged = {
|
||||
...webhook,
|
||||
...params.override,
|
||||
};
|
||||
|
||||
// テスト目的なのでUserWebhookServiceの機能を経由せず直接キューに追加する(チェック処理などをスキップする意図).
|
||||
// また、Jobの試行回数も1回だけ.
|
||||
this.queueService.userWebhookDeliver(merged, params.type, contents, { attempts: 1 });
|
||||
};
|
||||
|
||||
const dummyNote1 = generateDummyNote({
|
||||
userId: dummyUser1.id,
|
||||
user: dummyUser1,
|
||||
});
|
||||
const dummyReply1 = generateDummyNote({
|
||||
id: 'dummy-reply-1',
|
||||
replyId: dummyNote1.id,
|
||||
reply: dummyNote1,
|
||||
userId: dummyUser1.id,
|
||||
user: dummyUser1,
|
||||
});
|
||||
const dummyRenote1 = generateDummyNote({
|
||||
id: 'dummy-renote-1',
|
||||
renoteId: dummyNote1.id,
|
||||
renote: dummyNote1,
|
||||
userId: dummyUser2.id,
|
||||
user: dummyUser2,
|
||||
text: null,
|
||||
});
|
||||
const dummyMention1 = generateDummyNote({
|
||||
id: 'dummy-mention-1',
|
||||
userId: dummyUser1.id,
|
||||
user: dummyUser1,
|
||||
text: `@${dummyUser2.username} This is a mention to you.`,
|
||||
mentions: [dummyUser2.id],
|
||||
});
|
||||
|
||||
switch (params.type) {
|
||||
case 'note': {
|
||||
send(toPackedNote(dummyNote1));
|
||||
break;
|
||||
}
|
||||
case 'reply': {
|
||||
send(toPackedNote(dummyReply1));
|
||||
break;
|
||||
}
|
||||
case 'renote': {
|
||||
send(toPackedNote(dummyRenote1));
|
||||
break;
|
||||
}
|
||||
case 'mention': {
|
||||
send(toPackedNote(dummyMention1));
|
||||
break;
|
||||
}
|
||||
case 'follow': {
|
||||
send(toPackedUserDetailedNotMe(dummyUser1));
|
||||
break;
|
||||
}
|
||||
case 'followed': {
|
||||
send(toPackedUserLite(dummyUser2));
|
||||
break;
|
||||
}
|
||||
case 'unfollow': {
|
||||
send(toPackedUserDetailedNotMe(dummyUser3));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SystemWebhookのテスト送信を行う.
|
||||
* 送信されるペイロードはいずれもダミーの値で、実際にはデータベース上に存在しない.
|
||||
*
|
||||
* また、この関数経由で送信されるWebhookは以下の設定を無視する.
|
||||
* - Webhookそのものの有効・無効設定(isActive)
|
||||
* - 送信対象イベント(on)に関する設定
|
||||
*/
|
||||
@bindThis
|
||||
public async testSystemWebhook(
|
||||
params: {
|
||||
webhookId: MiSystemWebhook['id'],
|
||||
type: SystemWebhookEventType,
|
||||
override?: Partial<Omit<MiSystemWebhook, 'id'>>,
|
||||
},
|
||||
) {
|
||||
const webhooks = await this.systemWebhookService.fetchSystemWebhooks({ ids: [params.webhookId] });
|
||||
if (webhooks.length === 0) {
|
||||
throw new WebhookTestService.NoSuchWebhookError();
|
||||
}
|
||||
|
||||
const webhook = webhooks[0];
|
||||
const send = (contents: unknown) => {
|
||||
const merged = {
|
||||
...webhook,
|
||||
...params.override,
|
||||
};
|
||||
|
||||
// テスト目的なのでSystemWebhookServiceの機能を経由せず直接キューに追加する(チェック処理などをスキップする意図).
|
||||
// また、Jobの試行回数も1回だけ.
|
||||
this.queueService.systemWebhookDeliver(merged, params.type, contents, { attempts: 1 });
|
||||
};
|
||||
|
||||
switch (params.type) {
|
||||
case 'abuseReport': {
|
||||
send(generateAbuseReport({
|
||||
targetUserId: dummyUser1.id,
|
||||
targetUser: dummyUser1,
|
||||
reporterId: dummyUser2.id,
|
||||
reporter: dummyUser2,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'abuseReportResolved': {
|
||||
send(generateAbuseReport({
|
||||
targetUserId: dummyUser1.id,
|
||||
targetUser: dummyUser1,
|
||||
reporterId: dummyUser2.id,
|
||||
reporter: dummyUser2,
|
||||
assigneeId: dummyUser3.id,
|
||||
assignee: dummyUser3,
|
||||
resolved: true,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'userCreated': {
|
||||
send(toPackedUserLite(dummyUser1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -207,8 +207,28 @@ export class ApRequestService {
|
|||
|
||||
if ((contentType ?? '').split(';')[0].trimEnd().toLowerCase() === 'text/html' && _followAlternate === true) {
|
||||
const html = await res.text();
|
||||
const window = new Window();
|
||||
const window = new Window({
|
||||
settings: {
|
||||
disableJavaScriptEvaluation: true,
|
||||
disableJavaScriptFileLoading: true,
|
||||
disableCSSFileLoading: true,
|
||||
disableComputedStyleRendering: true,
|
||||
handleDisabledFileLoadingAsSuccess: true,
|
||||
navigation: {
|
||||
disableMainFrameNavigation: true,
|
||||
disableChildFrameNavigation: true,
|
||||
disableChildPageNavigation: true,
|
||||
disableFallbackToSetURL: true,
|
||||
},
|
||||
timer: {
|
||||
maxTimeout: 0,
|
||||
maxIntervalTime: 0,
|
||||
maxIntervalIterations: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
const document = window.document;
|
||||
try {
|
||||
document.documentElement.innerHTML = html;
|
||||
|
||||
const alternate = document.querySelector('head > link[rel="alternate"][type="application/activity+json"]');
|
||||
|
@ -218,6 +238,11 @@ export class ApRequestService {
|
|||
return await this.signedGet(href, user, false);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// something went wrong parsing the HTML, ignore the whole thing
|
||||
} finally {
|
||||
window.close();
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
|
|
|
@ -10,8 +10,9 @@
|
|||
* The getter will return a .bind version of the function
|
||||
* and memoize the result against a symbol on the instance
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function bindThis(target: any, key: string, descriptor: any) {
|
||||
let fn = descriptor.value;
|
||||
const fn = descriptor.value;
|
||||
|
||||
if (typeof fn !== 'function') {
|
||||
throw new TypeError(`@bindThis decorator can only be applied to methods not: ${typeof fn}`);
|
||||
|
@ -21,26 +22,18 @@ export function bindThis(target: any, key: string, descriptor: any) {
|
|||
configurable: true,
|
||||
get() {
|
||||
// eslint-disable-next-line no-prototype-builtins
|
||||
if (this === target.prototype || this.hasOwnProperty(key) ||
|
||||
typeof fn !== 'function') {
|
||||
if (this === target.prototype || this.hasOwnProperty(key)) {
|
||||
return fn;
|
||||
}
|
||||
|
||||
const boundFn = fn.bind(this);
|
||||
Object.defineProperty(this, key, {
|
||||
Reflect.defineProperty(this, key, {
|
||||
value: boundFn,
|
||||
configurable: true,
|
||||
get() {
|
||||
return boundFn;
|
||||
},
|
||||
set(value) {
|
||||
fn = value;
|
||||
delete this[key];
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
return boundFn;
|
||||
},
|
||||
set(value: any) {
|
||||
fn = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -144,7 +144,9 @@ export interface Schema extends OfSchema {
|
|||
readonly type?: TypeStringef;
|
||||
readonly nullable?: boolean;
|
||||
readonly optional?: boolean;
|
||||
readonly prefixItems?: ReadonlyArray<Schema>;
|
||||
readonly items?: Schema;
|
||||
readonly unevaluatedItems?: Schema | boolean;
|
||||
readonly properties?: Obj;
|
||||
readonly required?: ReadonlyArray<Extract<keyof NonNullable<this['properties']>, string>>;
|
||||
readonly description?: string;
|
||||
|
@ -198,6 +200,7 @@ type UnionSchemaType<a extends readonly any[], X extends Schema = a[number]> = X
|
|||
//type UnionObjectSchemaType<a extends readonly any[], X extends Schema = a[number]> = X extends any ? ObjectSchemaType<X> : never;
|
||||
type UnionObjType<s extends Obj, a extends readonly any[], X extends ReadonlyArray<keyof s> = a[number]> = X extends any ? ObjType<s, X> : never;
|
||||
type ArrayUnion<T> = T extends any ? Array<T> : never;
|
||||
type ArrayToTuple<X extends ReadonlyArray<Schema>> = { [K in keyof X]: SchemaType<X[K]> };
|
||||
|
||||
type ObjectSchemaTypeDef<p extends Schema> =
|
||||
p['ref'] extends keyof typeof refs ? Packed<p['ref']> :
|
||||
|
@ -232,6 +235,12 @@ export type SchemaTypeDef<p extends Schema> =
|
|||
p['items']['allOf'] extends ReadonlyArray<Schema> ? UnionToIntersection<UnionSchemaType<NonNullable<p['items']['allOf']>>>[] :
|
||||
never
|
||||
) :
|
||||
p['prefixItems'] extends ReadonlyArray<Schema> ? (
|
||||
p['items'] extends NonNullable<Schema> ? [...ArrayToTuple<p['prefixItems']>, ...SchemaType<p['items']>[]] :
|
||||
p['items'] extends false ? ArrayToTuple<p['prefixItems']> :
|
||||
p['unevaluatedItems'] extends false ? ArrayToTuple<p['prefixItems']> :
|
||||
[...ArrayToTuple<p['prefixItems']>, ...unknown[]]
|
||||
) :
|
||||
p['items'] extends NonNullable<Schema> ? SchemaType<p['items']>[] :
|
||||
any[]
|
||||
) :
|
||||
|
|
|
@ -85,7 +85,7 @@ export type MiNotification = {
|
|||
/**
|
||||
* アプリ通知のbody
|
||||
*/
|
||||
customBody: string | null;
|
||||
customBody: string;
|
||||
|
||||
/**
|
||||
* アプリ通知のheader
|
||||
|
|
|
@ -8,6 +8,7 @@ import { id } from './util/id.js';
|
|||
import { MiUser } from './User.js';
|
||||
|
||||
export const webhookEventTypes = ['mention', 'unfollow', 'follow', 'followed', 'note', 'reply', 'renote', 'reaction'] as const;
|
||||
export type WebhookEventTypes = typeof webhookEventTypes[number];
|
||||
|
||||
@Entity('webhook')
|
||||
export class MiWebhook {
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { ACHIEVEMENT_TYPES } from '@/core/AchievementService.js';
|
||||
import { notificationTypes } from '@/types.js';
|
||||
|
||||
const baseSchema = {
|
||||
|
@ -294,6 +295,7 @@ export const packedNotificationSchema = {
|
|||
achievement: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
enum: ACHIEVEMENT_TYPES,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
|
@ -311,11 +313,11 @@ export const packedNotificationSchema = {
|
|||
},
|
||||
header: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
icon: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
|
|
|
@ -92,6 +92,7 @@ import * as ep___admin_systemWebhook_delete from './endpoints/admin/system-webho
|
|||
import * as ep___admin_systemWebhook_list from './endpoints/admin/system-webhook/list.js';
|
||||
import * as ep___admin_systemWebhook_show from './endpoints/admin/system-webhook/show.js';
|
||||
import * as ep___admin_systemWebhook_update from './endpoints/admin/system-webhook/update.js';
|
||||
import * as ep___admin_systemWebhook_test from './endpoints/admin/system-webhook/test.js';
|
||||
import * as ep___announcements from './endpoints/announcements.js';
|
||||
import * as ep___announcements_show from './endpoints/announcements/show.js';
|
||||
import * as ep___antennas_create from './endpoints/antennas/create.js';
|
||||
|
@ -258,6 +259,7 @@ import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js';
|
|||
import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js';
|
||||
import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js';
|
||||
import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js';
|
||||
import * as ep___i_webhooks_test from './endpoints/i/webhooks/test.js';
|
||||
import * as ep___invite_create from './endpoints/invite/create.js';
|
||||
import * as ep___invite_delete from './endpoints/invite/delete.js';
|
||||
import * as ep___invite_list from './endpoints/invite/list.js';
|
||||
|
@ -475,6 +477,7 @@ const $admin_systemWebhook_delete: Provider = { provide: 'ep:admin/system-webhoo
|
|||
const $admin_systemWebhook_list: Provider = { provide: 'ep:admin/system-webhook/list', useClass: ep___admin_systemWebhook_list.default };
|
||||
const $admin_systemWebhook_show: Provider = { provide: 'ep:admin/system-webhook/show', useClass: ep___admin_systemWebhook_show.default };
|
||||
const $admin_systemWebhook_update: Provider = { provide: 'ep:admin/system-webhook/update', useClass: ep___admin_systemWebhook_update.default };
|
||||
const $admin_systemWebhook_test: Provider = { provide: 'ep:admin/system-webhook/test', useClass: ep___admin_systemWebhook_test.default };
|
||||
const $announcements: Provider = { provide: 'ep:announcements', useClass: ep___announcements.default };
|
||||
const $announcements_show: Provider = { provide: 'ep:announcements/show', useClass: ep___announcements_show.default };
|
||||
const $antennas_create: Provider = { provide: 'ep:antennas/create', useClass: ep___antennas_create.default };
|
||||
|
@ -641,6 +644,7 @@ const $i_webhooks_list: Provider = { provide: 'ep:i/webhooks/list', useClass: ep
|
|||
const $i_webhooks_show: Provider = { provide: 'ep:i/webhooks/show', useClass: ep___i_webhooks_show.default };
|
||||
const $i_webhooks_update: Provider = { provide: 'ep:i/webhooks/update', useClass: ep___i_webhooks_update.default };
|
||||
const $i_webhooks_delete: Provider = { provide: 'ep:i/webhooks/delete', useClass: ep___i_webhooks_delete.default };
|
||||
const $i_webhooks_test: Provider = { provide: 'ep:i/webhooks/test', useClass: ep___i_webhooks_test.default };
|
||||
const $invite_create: Provider = { provide: 'ep:invite/create', useClass: ep___invite_create.default };
|
||||
const $invite_delete: Provider = { provide: 'ep:invite/delete', useClass: ep___invite_delete.default };
|
||||
const $invite_list: Provider = { provide: 'ep:invite/list', useClass: ep___invite_list.default };
|
||||
|
@ -862,6 +866,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
|||
$admin_systemWebhook_list,
|
||||
$admin_systemWebhook_show,
|
||||
$admin_systemWebhook_update,
|
||||
$admin_systemWebhook_test,
|
||||
$announcements,
|
||||
$announcements_show,
|
||||
$antennas_create,
|
||||
|
@ -1028,6 +1033,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
|||
$i_webhooks_show,
|
||||
$i_webhooks_update,
|
||||
$i_webhooks_delete,
|
||||
$i_webhooks_test,
|
||||
$invite_create,
|
||||
$invite_delete,
|
||||
$invite_list,
|
||||
|
@ -1243,6 +1249,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
|||
$admin_systemWebhook_list,
|
||||
$admin_systemWebhook_show,
|
||||
$admin_systemWebhook_update,
|
||||
$admin_systemWebhook_test,
|
||||
$announcements,
|
||||
$announcements_show,
|
||||
$antennas_create,
|
||||
|
@ -1409,6 +1416,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
|||
$i_webhooks_show,
|
||||
$i_webhooks_update,
|
||||
$i_webhooks_delete,
|
||||
$i_webhooks_test,
|
||||
$invite_create,
|
||||
$invite_delete,
|
||||
$invite_list,
|
||||
|
|
|
@ -98,6 +98,7 @@ import * as ep___admin_systemWebhook_delete from './endpoints/admin/system-webho
|
|||
import * as ep___admin_systemWebhook_list from './endpoints/admin/system-webhook/list.js';
|
||||
import * as ep___admin_systemWebhook_show from './endpoints/admin/system-webhook/show.js';
|
||||
import * as ep___admin_systemWebhook_update from './endpoints/admin/system-webhook/update.js';
|
||||
import * as ep___admin_systemWebhook_test from './endpoints/admin/system-webhook/test.js';
|
||||
import * as ep___announcements from './endpoints/announcements.js';
|
||||
import * as ep___announcements_show from './endpoints/announcements/show.js';
|
||||
import * as ep___antennas_create from './endpoints/antennas/create.js';
|
||||
|
@ -264,6 +265,7 @@ import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js';
|
|||
import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js';
|
||||
import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js';
|
||||
import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js';
|
||||
import * as ep___i_webhooks_test from './endpoints/i/webhooks/test.js';
|
||||
import * as ep___invite_create from './endpoints/invite/create.js';
|
||||
import * as ep___invite_delete from './endpoints/invite/delete.js';
|
||||
import * as ep___invite_list from './endpoints/invite/list.js';
|
||||
|
@ -479,6 +481,7 @@ const eps = [
|
|||
['admin/system-webhook/list', ep___admin_systemWebhook_list],
|
||||
['admin/system-webhook/show', ep___admin_systemWebhook_show],
|
||||
['admin/system-webhook/update', ep___admin_systemWebhook_update],
|
||||
['admin/system-webhook/test', ep___admin_systemWebhook_test],
|
||||
['announcements', ep___announcements],
|
||||
['announcements/show', ep___announcements_show],
|
||||
['antennas/create', ep___antennas_create],
|
||||
|
@ -645,6 +648,7 @@ const eps = [
|
|||
['i/webhooks/show', ep___i_webhooks_show],
|
||||
['i/webhooks/update', ep___i_webhooks_update],
|
||||
['i/webhooks/delete', ep___i_webhooks_delete],
|
||||
['i/webhooks/test', ep___i_webhooks_test],
|
||||
['invite/create', ep___invite_create],
|
||||
['invite/delete', ep___invite_delete],
|
||||
['invite/list', ep___invite_list],
|
||||
|
|
|
@ -21,8 +21,7 @@ export const meta = {
|
|||
items: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
anyOf: [
|
||||
prefixItems: [
|
||||
{
|
||||
type: 'string',
|
||||
},
|
||||
|
@ -30,7 +29,7 @@ export const meta = {
|
|||
type: 'number',
|
||||
},
|
||||
],
|
||||
},
|
||||
unevaluatedItems: false,
|
||||
},
|
||||
example: [[
|
||||
'example.com',
|
||||
|
|
|
@ -21,8 +21,7 @@ export const meta = {
|
|||
items: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
anyOf: [
|
||||
prefixItems: [
|
||||
{
|
||||
type: 'string',
|
||||
},
|
||||
|
@ -30,7 +29,7 @@ export const meta = {
|
|||
type: 'number',
|
||||
},
|
||||
],
|
||||
},
|
||||
unevaluatedItems: false,
|
||||
},
|
||||
example: [[
|
||||
'example.com',
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import ms from 'ms';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { WebhookTestService } from '@/core/WebhookTestService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { systemWebhookEventTypes } from '@/models/SystemWebhook.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['webhooks'],
|
||||
|
||||
requireCredential: true,
|
||||
requireModerator: true,
|
||||
secure: true,
|
||||
kind: 'read:admin:system-webhook',
|
||||
|
||||
limit: {
|
||||
duration: ms('15min'),
|
||||
max: 60,
|
||||
},
|
||||
|
||||
errors: {
|
||||
noSuchWebhook: {
|
||||
message: 'No such webhook.',
|
||||
code: 'NO_SUCH_WEBHOOK',
|
||||
id: '0c52149c-e913-18f8-5dc7-74870bfe0cf9',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
webhookId: {
|
||||
type: 'string',
|
||||
format: 'misskey:id',
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: systemWebhookEventTypes,
|
||||
},
|
||||
override: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', nullable: false },
|
||||
secret: { type: 'string', nullable: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['webhookId', 'type'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private webhookTestService: WebhookTestService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps) => {
|
||||
try {
|
||||
await this.webhookTestService.testSystemWebhook({
|
||||
webhookId: ps.webhookId,
|
||||
type: ps.type,
|
||||
override: ps.override,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof WebhookTestService.NoSuchWebhookError) {
|
||||
throw new ApiError(meta.errors.noSuchWebhook);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -34,6 +34,12 @@ export const meta = {
|
|||
code: 'TOO_MANY_ANTENNAS',
|
||||
id: 'faf47050-e8b5-438c-913c-db2b1576fde4',
|
||||
},
|
||||
|
||||
emptyKeyword: {
|
||||
message: 'Either keywords or excludeKeywords is required.',
|
||||
code: 'EMPTY_KEYWORD',
|
||||
id: '53ee222e-1ddd-4f9a-92e5-9fb82ddb463a',
|
||||
},
|
||||
},
|
||||
|
||||
res: {
|
||||
|
@ -87,7 +93,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
if (ps.keywords.flat().every(x => x === '') && ps.excludeKeywords.flat().every(x => x === '')) {
|
||||
throw new Error('either keywords or excludeKeywords is required.');
|
||||
throw new ApiError(meta.errors.emptyKeyword);
|
||||
}
|
||||
|
||||
const currentAntennasCount = await this.antennasRepository.countBy({
|
||||
|
|
|
@ -32,6 +32,12 @@ export const meta = {
|
|||
code: 'NO_SUCH_USER_LIST',
|
||||
id: '1c6b35c9-943e-48c2-81e4-2844989407f7',
|
||||
},
|
||||
|
||||
emptyKeyword: {
|
||||
message: 'Either keywords or excludeKeywords is required.',
|
||||
code: 'EMPTY_KEYWORD',
|
||||
id: '721aaff6-4e1b-4d88-8de6-877fae9f68c4',
|
||||
},
|
||||
},
|
||||
|
||||
res: {
|
||||
|
@ -85,7 +91,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
super(meta, paramDef, async (ps, me) => {
|
||||
if (ps.keywords && ps.excludeKeywords) {
|
||||
if (ps.keywords.flat().every(x => x === '') && ps.excludeKeywords.flat().every(x => x === '')) {
|
||||
throw new Error('either keywords or excludeKeywords is required.');
|
||||
throw new ApiError(meta.errors.emptyKeyword);
|
||||
}
|
||||
}
|
||||
// Fetch the antenna
|
||||
|
|
|
@ -13,6 +13,7 @@ import { DI } from '@/di-symbols.js';
|
|||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
|
||||
// TODO: UserWebhook schemaの適用
|
||||
export const meta = {
|
||||
tags: ['webhooks'],
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ import { webhookEventTypes } from '@/models/Webhook.js';
|
|||
import type { WebhooksRepository } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
// TODO: UserWebhook schemaの適用
|
||||
export const meta = {
|
||||
tags: ['webhooks', 'account'],
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ import type { WebhooksRepository } from '@/models/_.js';
|
|||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '../../../error.js';
|
||||
|
||||
// TODO: UserWebhook schemaの適用
|
||||
export const meta = {
|
||||
tags: ['webhooks'],
|
||||
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import ms from 'ms';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { webhookEventTypes } from '@/models/Webhook.js';
|
||||
import { WebhookTestService } from '@/core/WebhookTestService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['webhooks'],
|
||||
|
||||
requireCredential: true,
|
||||
secure: true,
|
||||
kind: 'read:account',
|
||||
|
||||
limit: {
|
||||
duration: ms('15min'),
|
||||
max: 60,
|
||||
},
|
||||
|
||||
errors: {
|
||||
noSuchWebhook: {
|
||||
message: 'No such webhook.',
|
||||
code: 'NO_SUCH_WEBHOOK',
|
||||
id: '0c52149c-e913-18f8-5dc7-74870bfe0cf9',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
webhookId: {
|
||||
type: 'string',
|
||||
format: 'misskey:id',
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: webhookEventTypes,
|
||||
},
|
||||
override: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string' },
|
||||
secret: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['webhookId', 'type'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private webhookTestService: WebhookTestService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
try {
|
||||
await this.webhookTestService.testUserWebhook({
|
||||
webhookId: ps.webhookId,
|
||||
type: ps.type,
|
||||
override: ps.override,
|
||||
}, me);
|
||||
} catch (e) {
|
||||
if (e instanceof WebhookTestService.NoSuchWebhookError) {
|
||||
throw new ApiError(meta.errors.noSuchWebhook);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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', {
|
||||
|
|
|
@ -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;
|
||||
}`);
|
||||
}
|
||||
})();
|
|
@ -3,17 +3,6 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* BOOT LOADER
|
||||
* サーバーからレスポンスされるHTMLに埋め込まれるスクリプトで、以下の役割を持ちます。
|
||||
* - 翻訳ファイルをフェッチする。
|
||||
* - バージョンに基づいて適切なメインスクリプトを読み込む。
|
||||
* - キャッシュされたコンパイル済みテーマを適用する。
|
||||
* - クライアントの設定値に基づいて対応するHTMLクラス等を設定する。
|
||||
* テーマをこの段階で設定するのは、メインスクリプトが読み込まれる間もテーマを適用したいためです。
|
||||
* 注: webpackは介さないため、このファイルではrequireやimportは使えません。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので
|
||||
|
@ -166,7 +155,7 @@
|
|||
|
||||
if (!errorsElement) {
|
||||
document.body.innerHTML = `
|
||||
<svg class="icon-warning" xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-alert-triangle" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg class="icon-warning" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M12 9v2m0 4v.01"></path>
|
||||
<path d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75"></path>
|
||||
|
@ -320,6 +309,6 @@
|
|||
#errorInfo {
|
||||
width: 50%;
|
||||
}
|
||||
}`)
|
||||
}`);
|
||||
}
|
||||
})();
|
||||
|
|
|
@ -47,6 +47,7 @@ html {
|
|||
transform: translateY(70px);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
#splashSpinner > .spinner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -1,7 +1,7 @@
|
|||
block vars
|
||||
|
||||
block loadClientEntry
|
||||
- const clientEntry = config.clientEntry;
|
||||
- const entry = config.frontendEntry;
|
||||
|
||||
doctype html
|
||||
|
||||
|
@ -36,15 +36,13 @@ html
|
|||
link(rel='prefetch' href=serverErrorImageUrl)
|
||||
link(rel='prefetch' href=infoImageUrl)
|
||||
link(rel='prefetch' href=notFoundImageUrl)
|
||||
//- https://github.com/misskey-dev/misskey/issues/9842
|
||||
link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v3.3.0')
|
||||
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
|
||||
|
@ -70,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
|
||||
|
|
|
@ -228,6 +228,17 @@ describe('アンテナ', () => {
|
|||
assert.deepStrictEqual(response, expected);
|
||||
});
|
||||
|
||||
test('を作成する時キーワードが指定されていないとエラーになる', async () => {
|
||||
await failedApiCall({
|
||||
endpoint: 'antennas/create',
|
||||
parameters: { ...defaultParam, keywords: [[]], excludeKeywords: [[]] },
|
||||
user: alice
|
||||
}, {
|
||||
status: 400,
|
||||
code: 'EMPTY_KEYWORD',
|
||||
id: '53ee222e-1ddd-4f9a-92e5-9fb82ddb463a'
|
||||
})
|
||||
});
|
||||
//#endregion
|
||||
//#region 更新(antennas/update)
|
||||
|
||||
|
@ -255,6 +266,18 @@ describe('アンテナ', () => {
|
|||
id: '1c6b35c9-943e-48c2-81e4-2844989407f7',
|
||||
});
|
||||
});
|
||||
test('を変更する時キーワードが指定されていないとエラーになる', async () => {
|
||||
const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: alice });
|
||||
await failedApiCall({
|
||||
endpoint: 'antennas/update',
|
||||
parameters: { ...defaultParam, antennaId: antenna.id, keywords: [[]], excludeKeywords: [[]] },
|
||||
user: alice
|
||||
}, {
|
||||
status: 400,
|
||||
code: 'EMPTY_KEYWORD',
|
||||
id: '721aaff6-4e1b-4d88-8de6-877fae9f68c4'
|
||||
})
|
||||
});
|
||||
|
||||
//#endregion
|
||||
//#region 表示(antennas/show)
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
@ -6,6 +7,7 @@
|
|||
import { setTimeout } from 'node:timers/promises';
|
||||
import { afterEach, beforeEach, describe, expect, jest } from '@jest/globals';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { randomString } from '../utils.js';
|
||||
import { MiUser } from '@/models/User.js';
|
||||
import { MiSystemWebhook, SystemWebhookEventType } from '@/models/SystemWebhook.js';
|
||||
import { SystemWebhooksRepository, UsersRepository } from '@/models/_.js';
|
||||
|
@ -17,7 +19,6 @@ import { DI } from '@/di-symbols.js';
|
|||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
|
||||
import { randomString } from '../utils.js';
|
||||
|
||||
describe('SystemWebhookService', () => {
|
||||
let app: TestingModule;
|
||||
|
@ -313,7 +314,7 @@ describe('SystemWebhookService', () => {
|
|||
isActive: true,
|
||||
on: ['abuseReport'],
|
||||
});
|
||||
await service.enqueueSystemWebhook(webhook.id, 'abuseReport', { foo: 'bar' });
|
||||
await service.enqueueSystemWebhook(webhook.id, 'abuseReport', { foo: 'bar' } as any);
|
||||
|
||||
expect(queueService.systemWebhookDeliver).toHaveBeenCalled();
|
||||
});
|
||||
|
@ -323,7 +324,7 @@ describe('SystemWebhookService', () => {
|
|||
isActive: false,
|
||||
on: ['abuseReport'],
|
||||
});
|
||||
await service.enqueueSystemWebhook(webhook.id, 'abuseReport', { foo: 'bar' });
|
||||
await service.enqueueSystemWebhook(webhook.id, 'abuseReport', { foo: 'bar' } as any);
|
||||
|
||||
expect(queueService.systemWebhookDeliver).not.toHaveBeenCalled();
|
||||
});
|
||||
|
@ -337,8 +338,8 @@ describe('SystemWebhookService', () => {
|
|||
isActive: true,
|
||||
on: ['abuseReportResolved'],
|
||||
});
|
||||
await service.enqueueSystemWebhook(webhook1.id, 'abuseReport', { foo: 'bar' });
|
||||
await service.enqueueSystemWebhook(webhook2.id, 'abuseReport', { foo: 'bar' });
|
||||
await service.enqueueSystemWebhook(webhook1.id, 'abuseReport', { foo: 'bar' } as any);
|
||||
await service.enqueueSystemWebhook(webhook2.id, 'abuseReport', { foo: 'bar' } as any);
|
||||
|
||||
expect(queueService.systemWebhookDeliver).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
|
@ -0,0 +1,245 @@
|
|||
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, jest } from '@jest/globals';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { randomString } from '../utils.js';
|
||||
import { MiUser } from '@/models/User.js';
|
||||
import { MiWebhook, UsersRepository, WebhooksRepository } from '@/models/_.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { GlobalModule } from '@/GlobalModule.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import { UserWebhookService } from '@/core/UserWebhookService.js';
|
||||
|
||||
describe('UserWebhookService', () => {
|
||||
let app: TestingModule;
|
||||
let service: UserWebhookService;
|
||||
|
||||
// --------------------------------------------------------------------------------------
|
||||
|
||||
let usersRepository: UsersRepository;
|
||||
let userWebhooksRepository: WebhooksRepository;
|
||||
let idService: IdService;
|
||||
let queueService: jest.Mocked<QueueService>;
|
||||
|
||||
// --------------------------------------------------------------------------------------
|
||||
|
||||
let root: MiUser;
|
||||
|
||||
// --------------------------------------------------------------------------------------
|
||||
|
||||
async function createUser(data: Partial<MiUser> = {}) {
|
||||
return await usersRepository
|
||||
.insert({
|
||||
id: idService.gen(),
|
||||
...data,
|
||||
})
|
||||
.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
|
||||
}
|
||||
|
||||
async function createWebhook(data: Partial<MiWebhook> = {}) {
|
||||
return userWebhooksRepository
|
||||
.insert({
|
||||
id: idService.gen(),
|
||||
name: randomString(),
|
||||
on: ['mention'],
|
||||
url: 'https://example.com',
|
||||
secret: randomString(),
|
||||
userId: root.id,
|
||||
...data,
|
||||
})
|
||||
.then(x => userWebhooksRepository.findOneByOrFail(x.identifiers[0]));
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------------
|
||||
|
||||
async function beforeAllImpl() {
|
||||
app = await Test
|
||||
.createTestingModule({
|
||||
imports: [
|
||||
GlobalModule,
|
||||
],
|
||||
providers: [
|
||||
UserWebhookService,
|
||||
IdService,
|
||||
LoggerService,
|
||||
GlobalEventService,
|
||||
{
|
||||
provide: QueueService, useFactory: () => ({ systemWebhookDeliver: jest.fn() }),
|
||||
},
|
||||
],
|
||||
})
|
||||
.compile();
|
||||
|
||||
usersRepository = app.get(DI.usersRepository);
|
||||
userWebhooksRepository = app.get(DI.webhooksRepository);
|
||||
|
||||
service = app.get(UserWebhookService);
|
||||
idService = app.get(IdService);
|
||||
queueService = app.get(QueueService) as jest.Mocked<QueueService>;
|
||||
|
||||
app.enableShutdownHooks();
|
||||
}
|
||||
|
||||
async function afterAllImpl() {
|
||||
await app.close();
|
||||
}
|
||||
|
||||
async function beforeEachImpl() {
|
||||
root = await createUser({ isRoot: true, username: 'root', usernameLower: 'root' });
|
||||
}
|
||||
|
||||
async function afterEachImpl() {
|
||||
await usersRepository.delete({});
|
||||
await userWebhooksRepository.delete({});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------------
|
||||
|
||||
describe('アプリを毎回作り直す必要のないグループ', () => {
|
||||
beforeAll(beforeAllImpl);
|
||||
afterAll(afterAllImpl);
|
||||
beforeEach(beforeEachImpl);
|
||||
afterEach(afterEachImpl);
|
||||
|
||||
describe('fetchSystemWebhooks', () => {
|
||||
test('フィルタなし', async () => {
|
||||
const webhook1 = await createWebhook({
|
||||
active: true,
|
||||
on: ['mention'],
|
||||
});
|
||||
const webhook2 = await createWebhook({
|
||||
active: false,
|
||||
on: ['mention'],
|
||||
});
|
||||
const webhook3 = await createWebhook({
|
||||
active: true,
|
||||
on: ['reply'],
|
||||
});
|
||||
const webhook4 = await createWebhook({
|
||||
active: false,
|
||||
on: [],
|
||||
});
|
||||
|
||||
const fetchedWebhooks = await service.fetchWebhooks();
|
||||
expect(fetchedWebhooks).toEqual([webhook1, webhook2, webhook3, webhook4]);
|
||||
});
|
||||
|
||||
test('activeのみ', async () => {
|
||||
const webhook1 = await createWebhook({
|
||||
active: true,
|
||||
on: ['mention'],
|
||||
});
|
||||
const webhook2 = await createWebhook({
|
||||
active: false,
|
||||
on: ['mention'],
|
||||
});
|
||||
const webhook3 = await createWebhook({
|
||||
active: true,
|
||||
on: ['reply'],
|
||||
});
|
||||
const webhook4 = await createWebhook({
|
||||
active: false,
|
||||
on: [],
|
||||
});
|
||||
|
||||
const fetchedWebhooks = await service.fetchWebhooks({ isActive: true });
|
||||
expect(fetchedWebhooks).toEqual([webhook1, webhook3]);
|
||||
});
|
||||
|
||||
test('特定のイベントのみ', async () => {
|
||||
const webhook1 = await createWebhook({
|
||||
active: true,
|
||||
on: ['mention'],
|
||||
});
|
||||
const webhook2 = await createWebhook({
|
||||
active: false,
|
||||
on: ['mention'],
|
||||
});
|
||||
const webhook3 = await createWebhook({
|
||||
active: true,
|
||||
on: ['reply'],
|
||||
});
|
||||
const webhook4 = await createWebhook({
|
||||
active: false,
|
||||
on: [],
|
||||
});
|
||||
|
||||
const fetchedWebhooks = await service.fetchWebhooks({ on: ['mention'] });
|
||||
expect(fetchedWebhooks).toEqual([webhook1, webhook2]);
|
||||
});
|
||||
|
||||
test('activeな特定のイベントのみ', async () => {
|
||||
const webhook1 = await createWebhook({
|
||||
active: true,
|
||||
on: ['mention'],
|
||||
});
|
||||
const webhook2 = await createWebhook({
|
||||
active: false,
|
||||
on: ['mention'],
|
||||
});
|
||||
const webhook3 = await createWebhook({
|
||||
active: true,
|
||||
on: ['reply'],
|
||||
});
|
||||
const webhook4 = await createWebhook({
|
||||
active: false,
|
||||
on: [],
|
||||
});
|
||||
|
||||
const fetchedWebhooks = await service.fetchWebhooks({ on: ['mention'], isActive: true });
|
||||
expect(fetchedWebhooks).toEqual([webhook1]);
|
||||
});
|
||||
|
||||
test('ID指定', async () => {
|
||||
const webhook1 = await createWebhook({
|
||||
active: true,
|
||||
on: ['mention'],
|
||||
});
|
||||
const webhook2 = await createWebhook({
|
||||
active: false,
|
||||
on: ['mention'],
|
||||
});
|
||||
const webhook3 = await createWebhook({
|
||||
active: true,
|
||||
on: ['reply'],
|
||||
});
|
||||
const webhook4 = await createWebhook({
|
||||
active: false,
|
||||
on: [],
|
||||
});
|
||||
|
||||
const fetchedWebhooks = await service.fetchWebhooks({ ids: [webhook1.id, webhook4.id] });
|
||||
expect(fetchedWebhooks).toEqual([webhook1, webhook4]);
|
||||
});
|
||||
|
||||
test('ID指定(他条件とANDになるか見たい)', async () => {
|
||||
const webhook1 = await createWebhook({
|
||||
active: true,
|
||||
on: ['mention'],
|
||||
});
|
||||
const webhook2 = await createWebhook({
|
||||
active: false,
|
||||
on: ['mention'],
|
||||
});
|
||||
const webhook3 = await createWebhook({
|
||||
active: true,
|
||||
on: ['reply'],
|
||||
});
|
||||
const webhook4 = await createWebhook({
|
||||
active: false,
|
||||
on: [],
|
||||
});
|
||||
|
||||
const fetchedWebhooks = await service.fetchWebhooks({ ids: [webhook1.id, webhook4.id], isActive: false });
|
||||
expect(fetchedWebhooks).toEqual([webhook4]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,225 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { beforeAll, describe, jest } from '@jest/globals';
|
||||
import { WebhookTestService } from '@/core/WebhookTestService.js';
|
||||
import { UserWebhookService } from '@/core/UserWebhookService.js';
|
||||
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
|
||||
import { GlobalModule } from '@/GlobalModule.js';
|
||||
import { MiSystemWebhook, MiUser, MiWebhook, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
|
||||
describe('WebhookTestService', () => {
|
||||
let app: TestingModule;
|
||||
let service: WebhookTestService;
|
||||
|
||||
// --------------------------------------------------------------------------------------
|
||||
|
||||
let usersRepository: UsersRepository;
|
||||
let userProfilesRepository: UserProfilesRepository;
|
||||
let queueService: jest.Mocked<QueueService>;
|
||||
let userWebhookService: jest.Mocked<UserWebhookService>;
|
||||
let systemWebhookService: jest.Mocked<SystemWebhookService>;
|
||||
let idService: IdService;
|
||||
|
||||
let root: MiUser;
|
||||
let alice: MiUser;
|
||||
|
||||
async function createUser(data: Partial<MiUser> = {}) {
|
||||
const user = await usersRepository
|
||||
.insert({
|
||||
id: idService.gen(),
|
||||
...data,
|
||||
})
|
||||
.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
await userProfilesRepository.insert({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------------
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await Test.createTestingModule({
|
||||
imports: [
|
||||
GlobalModule,
|
||||
],
|
||||
providers: [
|
||||
WebhookTestService,
|
||||
IdService,
|
||||
{
|
||||
provide: QueueService, useFactory: () => ({
|
||||
systemWebhookDeliver: jest.fn(),
|
||||
userWebhookDeliver: jest.fn(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: UserWebhookService, useFactory: () => ({
|
||||
fetchWebhooks: jest.fn(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: SystemWebhookService, useFactory: () => ({
|
||||
fetchSystemWebhooks: jest.fn(),
|
||||
}),
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
usersRepository = app.get(DI.usersRepository);
|
||||
userProfilesRepository = app.get(DI.userProfilesRepository);
|
||||
|
||||
service = app.get(WebhookTestService);
|
||||
idService = app.get(IdService);
|
||||
queueService = app.get(QueueService) as jest.Mocked<QueueService>;
|
||||
userWebhookService = app.get(UserWebhookService) as jest.Mocked<UserWebhookService>;
|
||||
systemWebhookService = app.get(SystemWebhookService) as jest.Mocked<SystemWebhookService>;
|
||||
|
||||
app.enableShutdownHooks();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await createUser({ username: 'root', usernameLower: 'root', isRoot: true });
|
||||
alice = await createUser({ username: 'alice', usernameLower: 'alice', isRoot: false });
|
||||
|
||||
userWebhookService.fetchWebhooks.mockReturnValue(Promise.resolve([
|
||||
{ id: 'dummy-webhook', active: true, userId: alice.id } as MiWebhook,
|
||||
]));
|
||||
systemWebhookService.fetchSystemWebhooks.mockReturnValue(Promise.resolve([
|
||||
{ id: 'dummy-webhook', isActive: true } as MiSystemWebhook,
|
||||
]));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
queueService.systemWebhookDeliver.mockClear();
|
||||
queueService.userWebhookDeliver.mockClear();
|
||||
userWebhookService.fetchWebhooks.mockClear();
|
||||
systemWebhookService.fetchSystemWebhooks.mockClear();
|
||||
|
||||
await usersRepository.delete({});
|
||||
await userProfilesRepository.delete({});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------------------
|
||||
|
||||
describe('testUserWebhook', () => {
|
||||
test('note', async () => {
|
||||
await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'note' }, alice);
|
||||
|
||||
const calls = queueService.userWebhookDeliver.mock.calls[0];
|
||||
expect((calls[0] as any).id).toBe('dummy-webhook');
|
||||
expect(calls[1]).toBe('note');
|
||||
expect((calls[2] as any).id).toBe('dummy-note-1');
|
||||
});
|
||||
|
||||
test('reply', async () => {
|
||||
await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'reply' }, alice);
|
||||
|
||||
const calls = queueService.userWebhookDeliver.mock.calls[0];
|
||||
expect((calls[0] as any).id).toBe('dummy-webhook');
|
||||
expect(calls[1]).toBe('reply');
|
||||
expect((calls[2] as any).id).toBe('dummy-reply-1');
|
||||
});
|
||||
|
||||
test('renote', async () => {
|
||||
await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'renote' }, alice);
|
||||
|
||||
const calls = queueService.userWebhookDeliver.mock.calls[0];
|
||||
expect((calls[0] as any).id).toBe('dummy-webhook');
|
||||
expect(calls[1]).toBe('renote');
|
||||
expect((calls[2] as any).id).toBe('dummy-renote-1');
|
||||
});
|
||||
|
||||
test('mention', async () => {
|
||||
await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'mention' }, alice);
|
||||
|
||||
const calls = queueService.userWebhookDeliver.mock.calls[0];
|
||||
expect((calls[0] as any).id).toBe('dummy-webhook');
|
||||
expect(calls[1]).toBe('mention');
|
||||
expect((calls[2] as any).id).toBe('dummy-mention-1');
|
||||
});
|
||||
|
||||
test('follow', async () => {
|
||||
await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'follow' }, alice);
|
||||
|
||||
const calls = queueService.userWebhookDeliver.mock.calls[0];
|
||||
expect((calls[0] as any).id).toBe('dummy-webhook');
|
||||
expect(calls[1]).toBe('follow');
|
||||
expect((calls[2] as any).id).toBe('dummy-user-1');
|
||||
});
|
||||
|
||||
test('followed', async () => {
|
||||
await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'followed' }, alice);
|
||||
|
||||
const calls = queueService.userWebhookDeliver.mock.calls[0];
|
||||
expect((calls[0] as any).id).toBe('dummy-webhook');
|
||||
expect(calls[1]).toBe('followed');
|
||||
expect((calls[2] as any).id).toBe('dummy-user-2');
|
||||
});
|
||||
|
||||
test('unfollow', async () => {
|
||||
await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'unfollow' }, alice);
|
||||
|
||||
const calls = queueService.userWebhookDeliver.mock.calls[0];
|
||||
expect((calls[0] as any).id).toBe('dummy-webhook');
|
||||
expect(calls[1]).toBe('unfollow');
|
||||
expect((calls[2] as any).id).toBe('dummy-user-3');
|
||||
});
|
||||
|
||||
describe('NoSuchWebhookError', () => {
|
||||
test('user not match', async () => {
|
||||
userWebhookService.fetchWebhooks.mockClear();
|
||||
userWebhookService.fetchWebhooks.mockReturnValue(Promise.resolve([
|
||||
{ id: 'dummy-webhook', active: true } as MiWebhook,
|
||||
]));
|
||||
|
||||
await expect(service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'note' }, root))
|
||||
.rejects.toThrow(WebhookTestService.NoSuchWebhookError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('testSystemWebhook', () => {
|
||||
test('abuseReport', async () => {
|
||||
await service.testSystemWebhook({ webhookId: 'dummy-webhook', type: 'abuseReport' });
|
||||
|
||||
const calls = queueService.systemWebhookDeliver.mock.calls[0];
|
||||
expect((calls[0] as any).id).toBe('dummy-webhook');
|
||||
expect(calls[1]).toBe('abuseReport');
|
||||
expect((calls[2] as any).id).toBe('dummy-abuse-report1');
|
||||
expect((calls[2] as any).resolved).toBe(false);
|
||||
});
|
||||
|
||||
test('abuseReportResolved', async () => {
|
||||
await service.testSystemWebhook({ webhookId: 'dummy-webhook', type: 'abuseReportResolved' });
|
||||
|
||||
const calls = queueService.systemWebhookDeliver.mock.calls[0];
|
||||
expect((calls[0] as any).id).toBe('dummy-webhook');
|
||||
expect(calls[1]).toBe('abuseReportResolved');
|
||||
expect((calls[2] as any).id).toBe('dummy-abuse-report1');
|
||||
expect((calls[2] as any).resolved).toBe(true);
|
||||
});
|
||||
|
||||
test('userCreated', async () => {
|
||||
await service.testSystemWebhook({ webhookId: 'dummy-webhook', type: 'userCreated' });
|
||||
|
||||
const calls = queueService.systemWebhookDeliver.mock.calls[0];
|
||||
expect((calls[0] as any).id).toBe('dummy-webhook');
|
||||
expect(calls[1]).toBe('userCreated');
|
||||
expect((calls[2] as any).id).toBe('dummy-user-1');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1 @@
|
|||
/storybook-static
|
|
@ -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;
|
||||
}
|
|
@ -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 |
|
@ -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'],
|
||||
},
|
||||
},
|
||||
];
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,133 @@
|
|||
/*
|
||||
* 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 defaultLightTheme from '@@/themes/l-light.json5';
|
||||
import defaultDarkTheme from '@@/themes/d-dark.json5';
|
||||
import { MediaProxy } from '@@/js/media-proxy.js';
|
||||
import { applyTheme, assertIsTheme } from '@/theme.js';
|
||||
import { fetchCustomEmojis } from '@/custom-emojis.js';
|
||||
import { DI } from '@/di.js';
|
||||
import { serverMetadata } from '@/server-metadata.js';
|
||||
import { url } from '@@/js/config.js';
|
||||
import { parseEmbedParams } from '@@/js/embed-page.js';
|
||||
import { postMessageToParentWindow, setIframeId } from '@/post-message.js';
|
||||
|
||||
import type { Theme } from '@/theme.js';
|
||||
|
||||
console.log('Misskey Embed');
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const embedParams = parseEmbedParams(params);
|
||||
|
||||
if (_DEV_) console.log(embedParams);
|
||||
|
||||
function parseThemeOrNull(theme: string | null): Theme | null {
|
||||
if (theme == null) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(theme);
|
||||
if (assertIsTheme(parsed)) {
|
||||
return parsed;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const lightTheme = parseThemeOrNull(serverMetadata.defaultLightTheme) ?? defaultLightTheme;
|
||||
const darkTheme = parseThemeOrNull(serverMetadata.defaultDarkTheme) ?? defaultDarkTheme;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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 '@@/js/config.js';
|
||||
|
||||
defineProps<{
|
||||
user: Misskey.entities.UserLite;
|
||||
detail?: boolean;
|
||||
}>();
|
||||
|
||||
const host = toUnicode(hostRaw);
|
||||
</script>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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 '@@/js/worker-multi-dispatch.js';
|
||||
import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js';
|
||||
|
||||
const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => {
|
||||
// テスト環境で Web Worker インスタンスは作成できない
|
||||
if (import.meta.env.MODE === 'test') {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
resolve(canvas);
|
||||
return;
|
||||
}
|
||||
const testWorker = new TestWebGL2();
|
||||
testWorker.addEventListener('message', event => {
|
||||
if (event.data.result) {
|
||||
const workers = new WorkerMultiDispatch(
|
||||
() => new DrawBlurhash(),
|
||||
Math.min(navigator.hardwareConcurrency - 1, 4),
|
||||
);
|
||||
resolve(workers);
|
||||
if (_DEV_) console.log('WebGL2 in worker is supported!');
|
||||
} else {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
resolve(canvas);
|
||||
if (_DEV_) console.log('WebGL2 in worker is not supported...');
|
||||
}
|
||||
testWorker.terminate();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, shallowRef, watch, ref } from 'vue';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { render } from 'buraha';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
src?: string | null;
|
||||
hash?: string | null;
|
||||
alt?: string | null;
|
||||
title?: string | null;
|
||||
height?: number;
|
||||
width?: number;
|
||||
cover?: boolean;
|
||||
forceBlurhash?: boolean;
|
||||
onlyAvgColor?: boolean; // 軽量化のためにBlurhashを使わずに平均色だけを描画
|
||||
}>(), {
|
||||
src: null,
|
||||
alt: '',
|
||||
title: null,
|
||||
height: 64,
|
||||
width: 64,
|
||||
cover: true,
|
||||
forceBlurhash: false,
|
||||
onlyAvgColor: false,
|
||||
});
|
||||
|
||||
const viewId = uuid();
|
||||
const canvas = shallowRef<HTMLCanvasElement>();
|
||||
const root = shallowRef<HTMLDivElement>();
|
||||
const img = shallowRef<HTMLImageElement>();
|
||||
const loaded = ref(false);
|
||||
const canvasWidth = ref(64);
|
||||
const canvasHeight = ref(64);
|
||||
const imgWidth = ref(props.width);
|
||||
const imgHeight = ref(props.height);
|
||||
const bitmapTmp = ref<CanvasImageSource | undefined>();
|
||||
const hide = computed(() => !loaded.value || props.forceBlurhash);
|
||||
|
||||
function waitForDecode() {
|
||||
if (props.src != null && props.src !== '') {
|
||||
nextTick()
|
||||
.then(() => img.value?.decode())
|
||||
.then(() => {
|
||||
loaded.value = true;
|
||||
}, error => {
|
||||
console.log('Error occurred during decoding image', img.value, error);
|
||||
});
|
||||
} else {
|
||||
loaded.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch([() => props.width, () => props.height, root], () => {
|
||||
const ratio = props.width / props.height;
|
||||
if (ratio > 1) {
|
||||
canvasWidth.value = Math.round(64 * ratio);
|
||||
canvasHeight.value = 64;
|
||||
} else {
|
||||
canvasWidth.value = 64;
|
||||
canvasHeight.value = Math.round(64 / ratio);
|
||||
}
|
||||
|
||||
const clientWidth = root.value?.clientWidth ?? 300;
|
||||
imgWidth.value = clientWidth;
|
||||
imgHeight.value = Math.round(clientWidth / ratio);
|
||||
}, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
function drawImage(bitmap: CanvasImageSource) {
|
||||
// canvasがない(mountedされていない)場合はTmpに保存しておく
|
||||
if (!canvas.value) {
|
||||
bitmapTmp.value = bitmap;
|
||||
return;
|
||||
}
|
||||
|
||||
// canvasがあれば描画する
|
||||
bitmapTmp.value = undefined;
|
||||
const ctx = canvas.value.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.drawImage(bitmap, 0, 0, canvasWidth.value, canvasHeight.value);
|
||||
}
|
||||
|
||||
function drawAvg() {
|
||||
if (!canvas.value) return;
|
||||
|
||||
const color = (props.hash != null && extractAvgColorFromBlurhash(props.hash)) || '#888';
|
||||
|
||||
const ctx = canvas.value.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// avgColorでお茶をにごす
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, 0, canvasWidth.value, canvasHeight.value);
|
||||
}
|
||||
|
||||
async function draw() {
|
||||
if (import.meta.env.MODE === 'test' && props.hash == null) return;
|
||||
|
||||
drawAvg();
|
||||
|
||||
if (props.hash == null) return;
|
||||
|
||||
if (props.onlyAvgColor) return;
|
||||
|
||||
const work = await canvasPromise;
|
||||
if (work instanceof WorkerMultiDispatch) {
|
||||
work.postMessage(
|
||||
{
|
||||
id: viewId,
|
||||
hash: props.hash,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
render(props.hash, work);
|
||||
drawImage(work);
|
||||
} catch (error) {
|
||||
console.error('Error occurred during drawing blurhash', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function workerOnMessage(event: MessageEvent) {
|
||||
if (event.data.id !== viewId) return;
|
||||
drawImage(event.data.bitmap as ImageBitmap);
|
||||
}
|
||||
|
||||
canvasPromise.then(work => {
|
||||
if (work instanceof WorkerMultiDispatch) {
|
||||
work.addListener(workerOnMessage);
|
||||
}
|
||||
|
||||
draw();
|
||||
});
|
||||
|
||||
watch(() => props.src, () => {
|
||||
waitForDecode();
|
||||
});
|
||||
|
||||
watch(() => props.hash, () => {
|
||||
draw();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// drawImageがmountedより先に呼ばれている場合はここで描画する
|
||||
if (bitmapTmp.value) {
|
||||
drawImage(bitmapTmp.value);
|
||||
}
|
||||
waitForDecode();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
canvasPromise.then(work => {
|
||||
if (work instanceof WorkerMultiDispatch) {
|
||||
work.removeListener(workerOnMessage);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
&.cover {
|
||||
> .canvas,
|
||||
> .img {
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.canvas,
|
||||
.img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.img {
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
|
@ -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>
|
|
@ -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 '@@/js/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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,146 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-for="media in mediaList.filter(media => !previewable(media))" :key="media.id" :class="$style.banner">
|
||||
<XBanner :media="media" :href="originalEntityUrl"/>
|
||||
</div>
|
||||
<div v-if="mediaList.filter(media => previewable(media)).length > 0" :class="$style.container">
|
||||
<div
|
||||
:class="[
|
||||
$style.medias,
|
||||
count === 1 ? [$style.n1] : count === 2 ? $style.n2 : count === 3 ? $style.n3 : count === 4 ? $style.n4 : $style.nMany,
|
||||
]"
|
||||
>
|
||||
<div v-for="media in mediaList.filter(media => previewable(media))" :class="$style.media">
|
||||
<XVideo v-if="media.type.startsWith('video')" :key="`video:${media.id}`" :class="$style.mediaInner" :video="media" :href="originalEntityUrl"/>
|
||||
<XImage v-else-if="media.type.startsWith('image')" :key="`image:${media.id}`" :class="$style.mediaInner" class="image" :image="media" :raw="raw" :href="originalEntityUrl"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import XBanner from './EmMediaBanner.vue';
|
||||
import XImage from './EmMediaImage.vue';
|
||||
import XVideo from './EmMediaVideo.vue';
|
||||
import { FILE_TYPE_BROWSERSAFE } from '@@/js/const.js';
|
||||
|
||||
const props = defineProps<{
|
||||
mediaList: Misskey.entities.DriveFile[];
|
||||
raw?: boolean;
|
||||
|
||||
/** 埋め込みページ用 親要素の正規URL */
|
||||
originalEntityUrl: string;
|
||||
}>();
|
||||
|
||||
const count = computed(() => props.mediaList.filter(media => previewable(media)).length);
|
||||
|
||||
const previewable = (file: Misskey.entities.DriveFile): boolean => {
|
||||
if (file.type === 'image/svg+xml') return true; // svgのwebpublic/thumbnailはpngなのでtrue
|
||||
// FILE_TYPE_BROWSERSAFEに適合しないものはブラウザで表示するのに不適切
|
||||
return (file.type.startsWith('video') || file.type.startsWith('image')) && FILE_TYPE_BROWSERSAFE.includes(file.type);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.medias {
|
||||
display: grid;
|
||||
grid-gap: 8px;
|
||||
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
&.n1 {
|
||||
grid-template-rows: 1fr;
|
||||
|
||||
// default but fallback (expand)
|
||||
min-height: 64px;
|
||||
max-height: clamp(
|
||||
64px,
|
||||
50cqh,
|
||||
min(360px, 50vh)
|
||||
);
|
||||
|
||||
&.n116_9 {
|
||||
min-height: initial;
|
||||
max-height: initial;
|
||||
aspect-ratio: 16 / 9; // fallback
|
||||
}
|
||||
|
||||
&.n11_1{
|
||||
min-height: initial;
|
||||
max-height: initial;
|
||||
aspect-ratio: 1 / 1; // fallback
|
||||
}
|
||||
|
||||
&.n12_3 {
|
||||
min-height: initial;
|
||||
max-height: initial;
|
||||
aspect-ratio: 2 / 3; // fallback
|
||||
}
|
||||
}
|
||||
|
||||
&.n2 {
|
||||
aspect-ratio: 16/9;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
&.n3 {
|
||||
aspect-ratio: 16/9;
|
||||
grid-template-columns: 1fr 0.5fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
|
||||
> .media:nth-child(1) {
|
||||
grid-row: 1 / 3;
|
||||
}
|
||||
|
||||
> .media:nth-child(3) {
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 2 / 3;
|
||||
}
|
||||
}
|
||||
|
||||
&.n4 {
|
||||
aspect-ratio: 16/9;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
|
||||
&.nMany {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
> .media {
|
||||
aspect-ratio: 16/9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.media {
|
||||
overflow: hidden; // clipにするとバグる
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
|
||||
>.mediaInner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.banner {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
|
@ -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>
|
|
@ -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 '@@/js/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>
|
|
@ -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 '@@/js/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));
|
||||
}
|
|
@ -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, ref, shallowRef } 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 '@@/js/collapsed.js';
|
||||
import { url } from '@@/js/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>
|
|
@ -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 '@@/js/collapsed.js';
|
||||
import { serverMetadata } from '@/server-metadata.js';
|
||||
import { url } from '@@/js/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>
|
|
@ -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>
|
|
@ -0,0 +1,106 @@
|
|||
<!--
|
||||
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 EmAvatar from '@/components/EmAvatar.vue';
|
||||
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>
|
|
@ -0,0 +1,151 @@
|
|||
<!--
|
||||
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 EmA from '@/components/EmA.vue';
|
||||
import EmAvatar from '@/components/EmAvatar.vue';
|
||||
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>
|
|
@ -0,0 +1,52 @@
|
|||
<!--
|
||||
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);
|
||||
}
|
||||
|
||||
.note {
|
||||
border-bottom: 0.5px solid var(--divider);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,504 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<EmLoading v-if="fetching"/>
|
||||
|
||||
<EmError v-else-if="error" @retry="init()"/>
|
||||
|
||||
<div v-else-if="empty" key="_empty_" class="empty">
|
||||
<slot name="empty">
|
||||
<div class="_fullinfo">
|
||||
<div>{{ i18n.ts.nothing }}</div>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div v-else ref="rootEl">
|
||||
<div v-show="pagination.reversed && more" key="_more_" class="_margin">
|
||||
<button v-if="!moreFetching" class="_buttonPrimary" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMoreAhead">
|
||||
{{ i18n.ts.loadMore }}
|
||||
</button>
|
||||
<EmLoading v-else class="loading"/>
|
||||
</div>
|
||||
<slot :items="Array.from(items.values())" :fetching="fetching || moreFetching"></slot>
|
||||
<div v-show="!pagination.reversed && more" key="_more_" class="_margin">
|
||||
<button v-if="!moreFetching" class="_buttonRounded _buttonPrimary" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMore">
|
||||
{{ i18n.ts.loadMore }}
|
||||
</button>
|
||||
<EmLoading v-else class="loading"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, ComputedRef, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, shallowRef, watch } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { useDocumentVisibility } from '@@/js/use-document-visibility.js';
|
||||
import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@@/js/scroll.js';
|
||||
import { misskeyApi } from '@/misskey-api.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const SECOND_FETCH_LIMIT = 30;
|
||||
const TOLERANCE = 16;
|
||||
const APPEAR_MINIMUM_INTERVAL = 600;
|
||||
|
||||
export type Paging<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints> = {
|
||||
endpoint: E;
|
||||
limit: number;
|
||||
params?: Misskey.Endpoints[E]['req'] | ComputedRef<Misskey.Endpoints[E]['req']>;
|
||||
|
||||
/**
|
||||
* 検索APIのような、ページング不可なエンドポイントを利用する場合
|
||||
* (そのようなAPIをこの関数で使うのは若干矛盾してるけど)
|
||||
*/
|
||||
noPaging?: boolean;
|
||||
|
||||
/**
|
||||
* items 配列の中身を逆順にする(新しい方が最後)
|
||||
*/
|
||||
reversed?: boolean;
|
||||
|
||||
offsetMode?: boolean;
|
||||
|
||||
pageEl?: HTMLElement;
|
||||
};
|
||||
|
||||
type MisskeyEntity = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
_shouldInsertAd_?: boolean;
|
||||
[x: string]: any;
|
||||
};
|
||||
|
||||
type MisskeyEntityMap = Map<string, MisskeyEntity>;
|
||||
|
||||
function arrayToEntries(entities: MisskeyEntity[]): [string, MisskeyEntity][] {
|
||||
return entities.map(en => [en.id, en]);
|
||||
}
|
||||
|
||||
function concatMapWithArray(map: MisskeyEntityMap, entities: MisskeyEntity[]): MisskeyEntityMap {
|
||||
return new Map([...map, ...arrayToEntries(entities)]);
|
||||
}
|
||||
|
||||
</script>
|
||||
<script lang="ts" setup>
|
||||
import EmError from '@/components/EmError.vue';
|
||||
import EmLoading from '@/components/EmLoading.vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
pagination: Paging;
|
||||
disableAutoLoad?: boolean;
|
||||
displayLimit?: number;
|
||||
}>(), {
|
||||
displayLimit: 20,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'queue', count: number): void;
|
||||
(ev: 'status', error: boolean): void;
|
||||
}>();
|
||||
|
||||
const rootEl = shallowRef<HTMLElement>();
|
||||
|
||||
// 遡り中かどうか
|
||||
const backed = ref(false);
|
||||
|
||||
const scrollRemove = ref<(() => void) | null>(null);
|
||||
|
||||
/**
|
||||
* 表示するアイテムのソース
|
||||
* 最新が0番目
|
||||
*/
|
||||
const items = ref<MisskeyEntityMap>(new Map());
|
||||
|
||||
/**
|
||||
* タブが非アクティブなどの場合に更新を貯めておく
|
||||
* 最新が0番目
|
||||
*/
|
||||
const queue = ref<MisskeyEntityMap>(new Map());
|
||||
|
||||
const offset = ref(0);
|
||||
|
||||
/**
|
||||
* 初期化中かどうか(trueならEmLoadingで全て隠す)
|
||||
*/
|
||||
const fetching = ref(true);
|
||||
|
||||
const moreFetching = ref(false);
|
||||
const more = ref(false);
|
||||
const preventAppearFetchMore = ref(false);
|
||||
const preventAppearFetchMoreTimer = ref<number | null>(null);
|
||||
const isBackTop = ref(false);
|
||||
const empty = computed(() => items.value.size === 0);
|
||||
const error = ref(false);
|
||||
|
||||
const contentEl = computed(() => props.pagination.pageEl ?? rootEl.value);
|
||||
const scrollableElement = computed(() => contentEl.value ? getScrollContainer(contentEl.value) : document.body);
|
||||
|
||||
const visibility = useDocumentVisibility();
|
||||
|
||||
let isPausingUpdate = false;
|
||||
let timerForSetPause: number | null = null;
|
||||
const BACKGROUND_PAUSE_WAIT_SEC = 10;
|
||||
|
||||
// 先頭が表示されているかどうかを検出
|
||||
// https://qiita.com/mkataigi/items/0154aefd2223ce23398e
|
||||
const scrollObserver = ref<IntersectionObserver>();
|
||||
|
||||
watch([() => props.pagination.reversed, scrollableElement], () => {
|
||||
if (scrollObserver.value) scrollObserver.value.disconnect();
|
||||
|
||||
scrollObserver.value = new IntersectionObserver(entries => {
|
||||
backed.value = entries[0].isIntersecting;
|
||||
}, {
|
||||
root: scrollableElement.value,
|
||||
rootMargin: props.pagination.reversed ? '-100% 0px 100% 0px' : '100% 0px -100% 0px',
|
||||
threshold: 0.01,
|
||||
});
|
||||
}, { immediate: true });
|
||||
|
||||
watch(rootEl, () => {
|
||||
scrollObserver.value?.disconnect();
|
||||
nextTick(() => {
|
||||
if (rootEl.value) scrollObserver.value?.observe(rootEl.value);
|
||||
});
|
||||
});
|
||||
|
||||
watch([backed, contentEl], () => {
|
||||
if (!backed.value) {
|
||||
if (!contentEl.value) return;
|
||||
|
||||
scrollRemove.value = (props.pagination.reversed ? onScrollBottom : onScrollTop)(contentEl.value, executeQueue, TOLERANCE);
|
||||
} else {
|
||||
if (scrollRemove.value) scrollRemove.value();
|
||||
scrollRemove.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
// パラメータに何らかの変更があった際、再読込したい(チャンネル等のIDが変わったなど)
|
||||
watch(() => [props.pagination.endpoint, props.pagination.params], init, { deep: true });
|
||||
|
||||
watch(queue, (a, b) => {
|
||||
if (a.size === 0 && b.size === 0) return;
|
||||
emit('queue', queue.value.size);
|
||||
}, { deep: true });
|
||||
|
||||
watch(error, (n, o) => {
|
||||
if (n === o) return;
|
||||
emit('status', n);
|
||||
});
|
||||
|
||||
async function init(): Promise<void> {
|
||||
items.value = new Map();
|
||||
queue.value = new Map();
|
||||
fetching.value = true;
|
||||
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
|
||||
await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, {
|
||||
...params,
|
||||
limit: props.pagination.limit ?? 10,
|
||||
allowPartial: true,
|
||||
}).then(res => {
|
||||
for (let i = 0; i < res.length; i++) {
|
||||
const item = res[i];
|
||||
if (i === 3) item._shouldInsertAd_ = true;
|
||||
}
|
||||
|
||||
if (res.length === 0 || props.pagination.noPaging) {
|
||||
concatItems(res);
|
||||
more.value = false;
|
||||
} else {
|
||||
if (props.pagination.reversed) moreFetching.value = true;
|
||||
concatItems(res);
|
||||
more.value = true;
|
||||
}
|
||||
|
||||
offset.value = res.length;
|
||||
error.value = false;
|
||||
fetching.value = false;
|
||||
}, err => {
|
||||
error.value = true;
|
||||
fetching.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
const reload = (): Promise<void> => {
|
||||
return init();
|
||||
};
|
||||
|
||||
const fetchMore = async (): Promise<void> => {
|
||||
if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return;
|
||||
moreFetching.value = true;
|
||||
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
|
||||
await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, {
|
||||
...params,
|
||||
limit: SECOND_FETCH_LIMIT,
|
||||
...(props.pagination.offsetMode ? {
|
||||
offset: offset.value,
|
||||
} : {
|
||||
untilId: Array.from(items.value.keys()).at(-1),
|
||||
}),
|
||||
}).then(res => {
|
||||
for (let i = 0; i < res.length; i++) {
|
||||
const item = res[i];
|
||||
if (i === 10) item._shouldInsertAd_ = true;
|
||||
}
|
||||
|
||||
const reverseConcat = _res => {
|
||||
const oldHeight = scrollableElement.value ? scrollableElement.value.scrollHeight : getBodyScrollHeight();
|
||||
const oldScroll = scrollableElement.value ? scrollableElement.value.scrollTop : window.scrollY;
|
||||
|
||||
items.value = concatMapWithArray(items.value, _res);
|
||||
|
||||
return nextTick(() => {
|
||||
if (scrollableElement.value) {
|
||||
scroll(scrollableElement.value, { top: oldScroll + (scrollableElement.value.scrollHeight - oldHeight), behavior: 'instant' });
|
||||
} else {
|
||||
window.scroll({ top: oldScroll + (getBodyScrollHeight() - oldHeight), behavior: 'instant' });
|
||||
}
|
||||
|
||||
return nextTick();
|
||||
});
|
||||
};
|
||||
|
||||
if (res.length === 0) {
|
||||
if (props.pagination.reversed) {
|
||||
reverseConcat(res).then(() => {
|
||||
more.value = false;
|
||||
moreFetching.value = false;
|
||||
});
|
||||
} else {
|
||||
items.value = concatMapWithArray(items.value, res);
|
||||
more.value = false;
|
||||
moreFetching.value = false;
|
||||
}
|
||||
} else {
|
||||
if (props.pagination.reversed) {
|
||||
reverseConcat(res).then(() => {
|
||||
more.value = true;
|
||||
moreFetching.value = false;
|
||||
});
|
||||
} else {
|
||||
items.value = concatMapWithArray(items.value, res);
|
||||
more.value = true;
|
||||
moreFetching.value = false;
|
||||
}
|
||||
}
|
||||
offset.value += res.length;
|
||||
}, err => {
|
||||
moreFetching.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const fetchMoreAhead = async (): Promise<void> => {
|
||||
if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return;
|
||||
moreFetching.value = true;
|
||||
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
|
||||
await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, {
|
||||
...params,
|
||||
limit: SECOND_FETCH_LIMIT,
|
||||
...(props.pagination.offsetMode ? {
|
||||
offset: offset.value,
|
||||
} : {
|
||||
sinceId: Array.from(items.value.keys()).at(-1),
|
||||
}),
|
||||
}).then(res => {
|
||||
if (res.length === 0) {
|
||||
items.value = concatMapWithArray(items.value, res);
|
||||
more.value = false;
|
||||
} else {
|
||||
items.value = concatMapWithArray(items.value, res);
|
||||
more.value = true;
|
||||
}
|
||||
offset.value += res.length;
|
||||
moreFetching.value = false;
|
||||
}, err => {
|
||||
moreFetching.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Appear(IntersectionObserver)によってfetchMoreが呼ばれる場合、
|
||||
* APPEAR_MINIMUM_INTERVALミリ秒以内に2回fetchMoreが呼ばれるのを防ぐ
|
||||
*/
|
||||
const fetchMoreApperTimeoutFn = (): void => {
|
||||
preventAppearFetchMore.value = false;
|
||||
preventAppearFetchMoreTimer.value = null;
|
||||
};
|
||||
const fetchMoreAppearTimeout = (): void => {
|
||||
preventAppearFetchMore.value = true;
|
||||
preventAppearFetchMoreTimer.value = window.setTimeout(fetchMoreApperTimeoutFn, APPEAR_MINIMUM_INTERVAL);
|
||||
};
|
||||
|
||||
const appearFetchMore = async (): Promise<void> => {
|
||||
if (preventAppearFetchMore.value) return;
|
||||
await fetchMore();
|
||||
fetchMoreAppearTimeout();
|
||||
};
|
||||
|
||||
const appearFetchMoreAhead = async (): Promise<void> => {
|
||||
if (preventAppearFetchMore.value) return;
|
||||
await fetchMoreAhead();
|
||||
fetchMoreAppearTimeout();
|
||||
};
|
||||
|
||||
const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl.value!, TOLERANCE);
|
||||
|
||||
watch(visibility, () => {
|
||||
if (visibility.value === 'hidden') {
|
||||
timerForSetPause = window.setTimeout(() => {
|
||||
isPausingUpdate = true;
|
||||
timerForSetPause = null;
|
||||
},
|
||||
BACKGROUND_PAUSE_WAIT_SEC * 1000);
|
||||
} else { // 'visible'
|
||||
if (timerForSetPause) {
|
||||
clearTimeout(timerForSetPause);
|
||||
timerForSetPause = null;
|
||||
} else {
|
||||
isPausingUpdate = false;
|
||||
if (isTop()) {
|
||||
executeQueue();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 最新のものとして1つだけアイテムを追加する
|
||||
* ストリーミングから降ってきたアイテムはこれで追加する
|
||||
* @param item アイテム
|
||||
*/
|
||||
const prepend = (item: MisskeyEntity): void => {
|
||||
if (items.value.size === 0) {
|
||||
items.value.set(item.id, item);
|
||||
fetching.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTop() && !isPausingUpdate) unshiftItems([item]);
|
||||
else prependQueue(item);
|
||||
};
|
||||
|
||||
/**
|
||||
* 新着アイテムをitemsの先頭に追加し、displayLimitを適用する
|
||||
* @param newItems 新しいアイテムの配列
|
||||
*/
|
||||
function unshiftItems(newItems: MisskeyEntity[]) {
|
||||
const length = newItems.length + items.value.size;
|
||||
items.value = new Map([...arrayToEntries(newItems), ...items.value].slice(0, props.displayLimit));
|
||||
|
||||
if (length >= props.displayLimit) more.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 古いアイテムをitemsの末尾に追加し、displayLimitを適用する
|
||||
* @param oldItems 古いアイテムの配列
|
||||
*/
|
||||
function concatItems(oldItems: MisskeyEntity[]) {
|
||||
const length = oldItems.length + items.value.size;
|
||||
items.value = new Map([...items.value, ...arrayToEntries(oldItems)].slice(0, props.displayLimit));
|
||||
|
||||
if (length >= props.displayLimit) more.value = true;
|
||||
}
|
||||
|
||||
function executeQueue() {
|
||||
unshiftItems(Array.from(queue.value.values()));
|
||||
queue.value = new Map();
|
||||
}
|
||||
|
||||
function prependQueue(newItem: MisskeyEntity) {
|
||||
queue.value = new Map([[newItem.id, newItem], ...queue.value].slice(0, props.displayLimit) as [string, MisskeyEntity][]);
|
||||
}
|
||||
|
||||
/*
|
||||
* アイテムを末尾に追加する(使うの?)
|
||||
*/
|
||||
const appendItem = (item: MisskeyEntity): void => {
|
||||
items.value.set(item.id, item);
|
||||
};
|
||||
|
||||
const removeItem = (id: string) => {
|
||||
items.value.delete(id);
|
||||
queue.value.delete(id);
|
||||
};
|
||||
|
||||
const updateItem = (id: MisskeyEntity['id'], replacer: (old: MisskeyEntity) => MisskeyEntity): void => {
|
||||
const item = items.value.get(id);
|
||||
if (item) items.value.set(id, replacer(item));
|
||||
|
||||
const queueItem = queue.value.get(id);
|
||||
if (queueItem) queue.value.set(id, replacer(queueItem));
|
||||
};
|
||||
|
||||
onActivated(() => {
|
||||
isBackTop.value = false;
|
||||
});
|
||||
|
||||
onDeactivated(() => {
|
||||
isBackTop.value = props.pagination.reversed ? window.scrollY >= (rootEl.value ? rootEl.value.scrollHeight - window.innerHeight : 0) : window.scrollY === 0;
|
||||
});
|
||||
|
||||
function toBottom() {
|
||||
scrollToBottom(contentEl.value!);
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
init().then(() => {
|
||||
if (props.pagination.reversed) {
|
||||
nextTick(() => {
|
||||
setTimeout(toBottom, 800);
|
||||
|
||||
// scrollToBottomでmoreFetchingボタンが画面外まで出るまで
|
||||
// more = trueを遅らせる
|
||||
setTimeout(() => {
|
||||
moreFetching.value = false;
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timerForSetPause) {
|
||||
clearTimeout(timerForSetPause);
|
||||
timerForSetPause = null;
|
||||
}
|
||||
if (preventAppearFetchMoreTimer.value) {
|
||||
clearTimeout(preventAppearFetchMoreTimer.value);
|
||||
preventAppearFetchMoreTimer.value = null;
|
||||
}
|
||||
scrollObserver.value?.disconnect();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
items,
|
||||
queue,
|
||||
backed: backed.value,
|
||||
more,
|
||||
reload,
|
||||
prepend,
|
||||
append: appendItem,
|
||||
removeItem,
|
||||
updateItem,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.transition_fade_enterActive,
|
||||
.transition_fade_leaveActive {
|
||||
transition: opacity 0.125s ease;
|
||||
}
|
||||
.transition_fade_enterFrom,
|
||||
.transition_fade_leaveTo {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.more {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
</style>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,114 @@
|
|||
<!--
|
||||
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 '@@/js/config.js';
|
||||
import { shouldCollapsed } from '@@/js/collapsed.js';
|
||||
import EmA from '@/components/EmA.vue';
|
||||
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>
|
|
@ -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 '@@/js/intl-const.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
time: Date | string | number | null;
|
||||
origin?: Date | null;
|
||||
mode?: 'relative' | 'absolute' | 'detail';
|
||||
colored?: boolean;
|
||||
}>(), {
|
||||
origin: null,
|
||||
mode: 'relative',
|
||||
});
|
||||
|
||||
function getDateSafe(n: Date | string | number) {
|
||||
try {
|
||||
if (n instanceof Date) {
|
||||
return n;
|
||||
}
|
||||
return new Date(n);
|
||||
} catch (err) {
|
||||
return {
|
||||
getTime: () => NaN,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
|
||||
const _time = props.time == null ? NaN : getDateSafe(props.time).getTime();
|
||||
const invalid = Number.isNaN(_time);
|
||||
const absolute = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid;
|
||||
|
||||
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
|
||||
const now = ref(props.origin?.getTime() ?? Date.now());
|
||||
const ago = computed(() => (now.value - _time) / 1000/*ms*/);
|
||||
|
||||
const relative = computed<string>(() => {
|
||||
if (props.mode === 'absolute') return ''; // absoluteではrelativeを使わないので計算しない
|
||||
if (invalid) return i18n.ts._ago.invalid;
|
||||
|
||||
return (
|
||||
ago.value >= 31536000 ? i18n.tsx._ago.yearsAgo({ n: Math.round(ago.value / 31536000).toString() }) :
|
||||
ago.value >= 2592000 ? i18n.tsx._ago.monthsAgo({ n: Math.round(ago.value / 2592000).toString() }) :
|
||||
ago.value >= 604800 ? i18n.tsx._ago.weeksAgo({ n: Math.round(ago.value / 604800).toString() }) :
|
||||
ago.value >= 86400 ? i18n.tsx._ago.daysAgo({ n: Math.round(ago.value / 86400).toString() }) :
|
||||
ago.value >= 3600 ? i18n.tsx._ago.hoursAgo({ n: Math.round(ago.value / 3600).toString() }) :
|
||||
ago.value >= 60 ? i18n.tsx._ago.minutesAgo({ n: (~~(ago.value / 60)).toString() }) :
|
||||
ago.value >= 10 ? i18n.tsx._ago.secondsAgo({ n: (~~(ago.value % 60)).toString() }) :
|
||||
ago.value >= -3 ? i18n.ts._ago.justNow :
|
||||
ago.value < -31536000 ? i18n.tsx._timeIn.years({ n: Math.round(-ago.value / 31536000).toString() }) :
|
||||
ago.value < -2592000 ? i18n.tsx._timeIn.months({ n: Math.round(-ago.value / 2592000).toString() }) :
|
||||
ago.value < -604800 ? i18n.tsx._timeIn.weeks({ n: Math.round(-ago.value / 604800).toString() }) :
|
||||
ago.value < -86400 ? i18n.tsx._timeIn.days({ n: Math.round(-ago.value / 86400).toString() }) :
|
||||
ago.value < -3600 ? i18n.tsx._timeIn.hours({ n: Math.round(-ago.value / 3600).toString() }) :
|
||||
ago.value < -60 ? i18n.tsx._timeIn.minutes({ n: (~~(-ago.value / 60)).toString() }) :
|
||||
i18n.tsx._timeIn.seconds({ n: (~~(-ago.value % 60)).toString() })
|
||||
);
|
||||
});
|
||||
|
||||
let tickId: number;
|
||||
let currentInterval: number;
|
||||
|
||||
function tick() {
|
||||
now.value = Date.now();
|
||||
const nextInterval = ago.value < 60 ? 10000 : ago.value < 3600 ? 60000 : 180000;
|
||||
|
||||
if (currentInterval !== nextInterval) {
|
||||
if (tickId) window.clearInterval(tickId);
|
||||
currentInterval = nextInterval;
|
||||
tickId = window.setInterval(tick, nextInterval);
|
||||
}
|
||||
}
|
||||
|
||||
if (!invalid && props.origin === null && (props.mode === 'relative' || props.mode === 'detail')) {
|
||||
onMounted(() => {
|
||||
tick();
|
||||
});
|
||||
onUnmounted(() => {
|
||||
if (tickId) window.clearInterval(tickId);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.old1 {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.old1.old2 {
|
||||
color: var(--error);
|
||||
}
|
||||
</style>
|
|
@ -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>
|
|
@ -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 '@@/js/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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
|
@ -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>,
|
||||
};
|
|
@ -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 '@@/js/config.js';
|
||||
|
||||
export const i18n = markRaw(new I18n<Locale>(locale, _DEV_));
|
||||
|
||||
export function updateI18n(newLocale: Locale) {
|
||||
i18n.locale = newLocale;
|
||||
}
|
|
@ -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>
|
|
@ -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 '@@/js/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;
|
||||
}
|
|
@ -0,0 +1,141 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<EmLoading 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 EmLoading from '@/components/EmLoading.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 '@@/js/config.js';
|
||||
import { isLink } from '@@/js/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: 3px;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -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>
|
|
@ -0,0 +1,48 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.noteEmbedRoot">
|
||||
<EmLoading v-if="loading"/>
|
||||
<EmNoteDetailed v-else-if="note" :note="note"/>
|
||||
<XNotFound v-else/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import EmNoteDetailed from '@/components/EmNoteDetailed.vue';
|
||||
import EmLoading from '@/components/EmLoading.vue';
|
||||
import XNotFound from '@/pages/not-found.vue';
|
||||
import { misskeyApi } from '@/misskey-api.js';
|
||||
|
||||
const props = defineProps<{
|
||||
noteId: string;
|
||||
}>();
|
||||
|
||||
const note = ref<Misskey.entities.Note | null>(null);
|
||||
const loading = ref(true);
|
||||
|
||||
// TODO: クライアント側でAPIを叩くのは二度手間なので予めHTMLに埋め込んでおく
|
||||
misskeyApi('notes/show', {
|
||||
noteId: props.noteId,
|
||||
}).then(res => {
|
||||
// リモートのノートは埋め込ませない
|
||||
if (res.url == null && res.uri == null) {
|
||||
note.value = res;
|
||||
}
|
||||
loading.value = false;
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
loading.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.noteEmbedRoot {
|
||||
background-color: var(--panel);
|
||||
}
|
||||
</style>
|
|
@ -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 '@@/js/config.js';
|
||||
import { isLink } from '@@/js/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: 3px;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -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 '@@/js/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: 3px;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -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,
|
||||
}, '*');
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { misskeyApi } from '@/misskey-api.js';
|
||||
|
||||
const providedMetaEl = document.getElementById('misskey_meta');
|
||||
|
||||
const _serverMetadata: Misskey.entities.MetaDetailed | null = (providedMetaEl && providedMetaEl.textContent) ? JSON.parse(providedMetaEl.textContent) : null;
|
||||
|
||||
// NOTE: devモードのときしか _serverMetadata が null になることは無い
|
||||
export const serverMetadata: Misskey.entities.MetaDetailed = _serverMetadata ?? await misskeyApi('meta', {
|
||||
detail: true,
|
||||
});
|
|
@ -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%); }
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue