mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-05-09 16:45:33 +02:00
Compare commits
35 Commits
2025.12.2
...
minify-nod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cce6dff51 | ||
|
|
6e99acf7a7 | ||
|
|
553a147396 | ||
|
|
7bcfeba7e5 | ||
|
|
4f65c1529b | ||
|
|
589ae8d4c6 | ||
|
|
0be4405a79 | ||
|
|
2fba2e7049 | ||
|
|
96b03a7179 | ||
|
|
cdb958cdf0 | ||
|
|
245775ea87 | ||
|
|
40d55fc6a3 | ||
|
|
9c22538454 | ||
|
|
a1ba403f9a | ||
|
|
443e1ed29e | ||
|
|
b5454cb2c4 | ||
|
|
8577f10456 | ||
|
|
16ffd88ecc | ||
|
|
866e675134 | ||
|
|
01aa56c602 | ||
|
|
ff7d2c1083 | ||
|
|
404fca6c2d | ||
|
|
3fe0477cac | ||
|
|
97d485bdd2 | ||
|
|
4285303c81 | ||
|
|
14f58255ee | ||
|
|
b69b0acf59 | ||
|
|
7a5430199f | ||
|
|
c32307dca4 | ||
|
|
bc78bb9b8e | ||
|
|
a33b003282 | ||
|
|
74e847a04d | ||
|
|
06657c81d3 | ||
|
|
5c5e965151 | ||
|
|
b07a1e692f |
@@ -6,6 +6,7 @@
|
||||
Dockerfile
|
||||
build/
|
||||
built/
|
||||
src-js/
|
||||
db/
|
||||
.devcontainer/compose.yml
|
||||
node_modules/
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/01_bug-report.yml
vendored
4
.github/ISSUE_TEMPLATE/01_bug-report.yml
vendored
@@ -54,7 +54,7 @@ body:
|
||||
* Model and OS of the device(s): MacBook Pro (14inch, 2021), macOS Ventura 13.4
|
||||
* Browser: Chrome 113.0.5672.126
|
||||
* Server URL: misskey.example.com
|
||||
* Misskey: 2025.x.x
|
||||
* Misskey: 2026.x.x
|
||||
value: |
|
||||
* Model and OS of the device(s):
|
||||
* Browser:
|
||||
@@ -74,7 +74,7 @@ body:
|
||||
|
||||
Examples:
|
||||
* Installation Method or Hosting Service: docker compose, k8s/docker, systemd, "Misskey install shell script", development environment
|
||||
* Misskey: 2025.x.x
|
||||
* Misskey: 2026.x.x
|
||||
* Node: 20.x.x
|
||||
* PostgreSQL: 18.x.x
|
||||
* Redis: 7.x.x
|
||||
|
||||
39
.github/workflows/dockle.yml
vendored
39
.github/workflows/dockle.yml
vendored
@@ -11,6 +11,7 @@ on:
|
||||
jobs:
|
||||
dockle:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
DOCKER_CONTENT_TRUST: 1
|
||||
DOCKLE_VERSION: 0.4.15
|
||||
@@ -20,29 +21,33 @@ jobs:
|
||||
|
||||
- name: Download and install dockle v${{ env.DOCKLE_VERSION }}
|
||||
run: |
|
||||
set -eux
|
||||
curl -L -o dockle.deb "https://github.com/goodwithtech/dockle/releases/download/v${DOCKLE_VERSION}/dockle_${DOCKLE_VERSION}_Linux-64bit.deb"
|
||||
sudo dpkg -i dockle.deb
|
||||
|
||||
- run: |
|
||||
cp .config/docker_example.env .config/docker.env
|
||||
cp ./compose_example.yml ./compose.yml
|
||||
|
||||
- run: |
|
||||
docker compose up -d web
|
||||
IMAGE_ID=$(docker compose images --format json web | jq -r '.[0].ID')
|
||||
docker tag "${IMAGE_ID}" misskey-web:latest
|
||||
|
||||
- name: Prune docker junk (optional but recommended)
|
||||
- name: Build web image (docker build)
|
||||
run: |
|
||||
docker system prune -af
|
||||
docker volume prune -f
|
||||
set -eux
|
||||
docker build -t "misskey-web:ci" .
|
||||
docker image ls
|
||||
|
||||
- name: Save image for Dockle
|
||||
- name: Mount tmpfs for Dockle tar
|
||||
env:
|
||||
TMPFS_SIZE: 8G
|
||||
run: |
|
||||
docker save misskey-web:latest -o ./misskey-web.tar
|
||||
ls -lh ./misskey-web.tar
|
||||
set -eux
|
||||
sudo mkdir -p /mnt/dockle-tmp
|
||||
sudo mount -t tmpfs -o size=${{ env.TMPFS_SIZE }} tmpfs /mnt/dockle-tmp
|
||||
free -h
|
||||
df -h
|
||||
|
||||
- name: Run Dockle with tar input
|
||||
- name: Save image tar into tmpfs
|
||||
run: |
|
||||
dockle --exit-code 1 --input ./misskey-web.tar
|
||||
set -eux
|
||||
docker save misskey-web:ci -o /mnt/dockle-tmp/misskey-web.tar
|
||||
ls -lh /mnt/dockle-tmp/misskey-web.tar
|
||||
|
||||
- name: Run Dockle Scan (tar input)
|
||||
run: |
|
||||
set -eux
|
||||
dockle --exit-code 1 --input /mnt/dockle-tmp/misskey-web.tar
|
||||
|
||||
7
.github/workflows/test-backend.yml
vendored
7
.github/workflows/test-backend.yml
vendored
@@ -48,6 +48,13 @@ jobs:
|
||||
image: redis:7
|
||||
ports:
|
||||
- 56312:6379
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.3.4
|
||||
ports:
|
||||
- 57712:7700
|
||||
env:
|
||||
MEILI_NO_ANALYTICS: true
|
||||
MEILI_ENV: development
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.1
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -46,6 +46,7 @@ docker-compose.yml
|
||||
built
|
||||
built-test
|
||||
js-built
|
||||
src-js
|
||||
/data
|
||||
/.cache-loader
|
||||
/db
|
||||
|
||||
27
CHANGELOG.md
27
CHANGELOG.md
@@ -1,3 +1,30 @@
|
||||
## 2026.1.0
|
||||
|
||||
### Note
|
||||
- `users/following` の `birthday` プロパティは非推奨になりました。代わりに `users/get-following-birthday-users` をご利用ください。
|
||||
|
||||
### General
|
||||
- Enhance: 「もうすぐ誕生日のユーザー」ウィジェットで、誕生日が至近のユーザーも表示できるように
|
||||
(Cherry-picked from https://github.com/MisskeyIO/misskey)
|
||||
- 「今日誕生日のユーザー」は「もうすぐ誕生日のユーザー」に名称変更されました
|
||||
- 依存関係の更新
|
||||
|
||||
### Client
|
||||
- Enhance: ドライブのファイル一覧で自動でもっと見るを利用可能に
|
||||
- Enhance: ウィジェットの表示設定をプレビューを見ながら行えるように
|
||||
- Enhance: ウィジェットの設定項目のラベルの多言語対応
|
||||
- Fix: ドライブクリーナーでファイルを削除しても画面に反映されない問題を修正 #16061
|
||||
- Fix: 非ログイン時にログインを求めるダイアログが表示された後にダイアログのぼかしが解除されず操作不能になることがある問題を修正
|
||||
- Fix: ドライブのソートが「登録日(昇順)」の場合に正しく動作しない問題を修正
|
||||
- Fix: 管理画面でアーカイブ済のお知らせを表示した際にアクティブなお知らせが多い旨の警告が出る問題を修正
|
||||
- Fix: ファイルタブのセンシティブメディアを開く際に確認ダイアログを出す設定が適用されない問題を修正
|
||||
|
||||
### Server
|
||||
- Enhance: OAuthのクライアント情報取得(Client Information Discovery)において、IndieWeb Living Standard 11 July 2024で定義されているJSONドキュメント形式に対応しました
|
||||
- JSONによるClient Information Discoveryを行うには、レスポンスの`Content-Type`ヘッダーが`application/json`である必要があります
|
||||
- 従来の実装(12 February 2022版・HTML Microformat形式)も引き続きサポートされます
|
||||
- Enhance: メモリ使用量を削減
|
||||
|
||||
## 2025.12.2
|
||||
|
||||
### Note
|
||||
|
||||
2
COPYING
2
COPYING
@@ -1,5 +1,5 @@
|
||||
Unless otherwise stated this repository is
|
||||
Copyright © 2014-2025 syuilo and contributors
|
||||
Copyright © 2014-2026 syuilo and contributors
|
||||
|
||||
And is distributed under The GNU Affero General Public License Version 3, you should have received a copy of the license file as LICENSE.
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-js/
|
||||
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-reversi/built ./packages/misskey-reversi/built
|
||||
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-bubble-game/built ./packages/misskey-bubble-game/built
|
||||
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/backend/built ./packages/backend/built
|
||||
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/backend/src-js ./packages/backend/src-js
|
||||
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/i18n/built ./packages/i18n/built
|
||||
COPY --chown=misskey:misskey --from=native-builder /misskey/fluent-emojis /misskey/fluent-emojis
|
||||
COPY --chown=misskey:misskey . ./
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
|
||||
[](https://deepwiki.com/misskey-dev/misskey)
|
||||
|
||||
<a href="https://flatt.tech/oss/gmo/trampoline" target="_blank"><img src="https://flatt.tech/assets/images/badges/gmo-oss.svg" height="24px"/></a>
|
||||
|
||||
</div>
|
||||
|
||||
## Thanks
|
||||
|
||||
@@ -1406,6 +1406,7 @@ youAreAdmin: "あなたは管理者です"
|
||||
frame: "フレーム"
|
||||
presets: "プリセット"
|
||||
zeroPadding: "ゼロ埋め"
|
||||
nothingToConfigure: "設定項目はありません"
|
||||
|
||||
_imageEditing:
|
||||
_vars:
|
||||
@@ -2599,9 +2600,48 @@ _widgets:
|
||||
_userList:
|
||||
chooseList: "リストを選択"
|
||||
clicker: "クリッカー"
|
||||
birthdayFollowings: "今日誕生日のユーザー"
|
||||
birthdayFollowings: "もうすぐ誕生日のユーザー"
|
||||
chat: "ダイレクトメッセージ"
|
||||
|
||||
_widgetOptions:
|
||||
showHeader: "ヘッダーを表示"
|
||||
transparent: "背景を透明にする"
|
||||
height: "高さ"
|
||||
_button:
|
||||
colored: "色付き"
|
||||
_clock:
|
||||
size: "サイズ"
|
||||
thickness: "針の太さ"
|
||||
thicknessThin: "細い"
|
||||
thicknessMedium: "普通"
|
||||
thicknessThick: "太い"
|
||||
graduations: "文字盤の目盛り"
|
||||
graduationDots: "ドット"
|
||||
graduationArabic: "アラビア数字"
|
||||
fadeGraduations: "目盛りをフェード"
|
||||
sAnimation: "秒針のアニメーション"
|
||||
sAnimationElastic: "リアル"
|
||||
sAnimationEaseOut: "滑らか"
|
||||
twentyFour: "24時間表示"
|
||||
labelTime: "時刻"
|
||||
labelTz: "タイムゾーン"
|
||||
labelTimeAndTz: "時刻とタイムゾーン"
|
||||
timezone: "タイムゾーン"
|
||||
showMs: "ミリ秒を表示"
|
||||
showLabel: "ラベルを表示"
|
||||
_jobQueue:
|
||||
sound: "音を鳴らす"
|
||||
_rss:
|
||||
url: "RSSフィードのURL"
|
||||
refreshIntervalSec: "更新間隔(秒)"
|
||||
maxEntries: "最大表示件数"
|
||||
_rssTicker:
|
||||
shuffle: "表示順をシャッフル"
|
||||
duration: "ティッカーのスクロール速度(秒)"
|
||||
reverse: "逆方向にスクロール"
|
||||
_birthdayFollowings:
|
||||
period: "期間"
|
||||
|
||||
_cw:
|
||||
hide: "隠す"
|
||||
show: "もっと見る"
|
||||
@@ -3418,7 +3458,6 @@ _imageEffector:
|
||||
title: "エフェクト"
|
||||
addEffect: "エフェクトを追加"
|
||||
discardChangesConfirm: "変更を破棄して終了しますか?"
|
||||
nothingToConfigure: "設定項目はありません"
|
||||
failedToLoadImage: "画像の読み込みに失敗しました"
|
||||
|
||||
_fxs:
|
||||
|
||||
27
package.json
27
package.json
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "misskey",
|
||||
"version": "2025.12.2",
|
||||
"version": "2026.1.0-alpha.1",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/misskey-dev/misskey.git"
|
||||
},
|
||||
"packageManager": "pnpm@10.25.0",
|
||||
"packageManager": "pnpm@10.26.2",
|
||||
"workspaces": [
|
||||
"packages/misskey-js",
|
||||
"packages/i18n",
|
||||
@@ -28,7 +28,7 @@
|
||||
"build": "pnpm build-pre && pnpm -r build && pnpm build-assets",
|
||||
"build-storybook": "pnpm --filter frontend build-storybook",
|
||||
"build-misskey-js-with-types": "pnpm build-pre && pnpm --filter backend... --filter=!misskey-js build && pnpm --filter backend generate-api-json --no-build && ncp packages/backend/built/api.json packages/misskey-js/generator/api.json && pnpm --filter misskey-js update-autogen-code && pnpm --filter misskey-js build && pnpm --filter misskey-js api",
|
||||
"start": "pnpm check:connect && cd packages/backend && pnpm compile-config && node ./built/boot/entry.js",
|
||||
"start": "cd packages/backend && pnpm compile-config && node ./built/boot/entry.js",
|
||||
"start:inspect": "cd packages/backend && pnpm compile-config && node --inspect ./built/boot/entry.js",
|
||||
"start:test": "ncp ./.github/misskey/test.yml ./.config/test.yml && cd packages/backend && cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./built/boot/entry.js",
|
||||
"cli": "cd packages/backend && pnpm cli",
|
||||
@@ -58,28 +58,29 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"cssnano": "7.1.2",
|
||||
"esbuild": "0.27.1",
|
||||
"esbuild": "0.27.2",
|
||||
"execa": "9.6.1",
|
||||
"ignore-walk": "8.0.0",
|
||||
"js-yaml": "4.1.1",
|
||||
"postcss": "8.5.6",
|
||||
"tar": "7.5.2",
|
||||
"terser": "5.44.1",
|
||||
"typescript": "5.9.3"
|
||||
"terser": "5.44.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.39.1",
|
||||
"@eslint/js": "9.39.2",
|
||||
"@misskey-dev/eslint-plugin": "2.2.0",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/node": "24.10.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.49.0",
|
||||
"@typescript-eslint/parser": "8.49.0",
|
||||
"@types/node": "24.10.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.50.1",
|
||||
"@typescript-eslint/parser": "8.50.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20251226.1",
|
||||
"cross-env": "10.1.0",
|
||||
"cypress": "15.7.1",
|
||||
"eslint": "9.39.1",
|
||||
"cypress": "15.8.1",
|
||||
"eslint": "9.39.2",
|
||||
"globals": "16.5.0",
|
||||
"ncp": "2.0.0",
|
||||
"pnpm": "10.25.0",
|
||||
"pnpm": "10.26.2",
|
||||
"typescript": "5.9.3",
|
||||
"start-server-and-test": "2.1.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
180
packages/backend/build.js
Normal file
180
packages/backend/build.js
Normal file
@@ -0,0 +1,180 @@
|
||||
import fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import * as esbuild from 'esbuild';
|
||||
import { swcPlugin } from 'esbuild-plugin-swc';
|
||||
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
const _package = JSON.parse(fs.readFileSync(_dirname + '/package.json', 'utf-8'));
|
||||
|
||||
const resolveTsPathsPlugin = {
|
||||
name: 'resolve-ts-paths',
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^\.{1,2}\/.*\.js$/ }, (args) => {
|
||||
if (args.importer) {
|
||||
const absPath = join(args.resolveDir, args.path);
|
||||
const tsPath = absPath.slice(0, -3) + '.ts';
|
||||
if (fs.existsSync(tsPath)) return { path: tsPath };
|
||||
const tsxPath = absPath.slice(0, -3) + '.tsx';
|
||||
if (fs.existsSync(tsxPath)) return { path: tsxPath };
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const externalIpaddrPlugin = {
|
||||
name: 'external-ipaddr',
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^ipaddr\.js$/ }, (args) => {
|
||||
return { path: args.path, external: true };
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/** @type {import('esbuild').BuildOptions} */
|
||||
const options = {
|
||||
entryPoints: ['./src/boot/entry.ts'],
|
||||
minify: true,
|
||||
keepNames: true,
|
||||
bundle: true,
|
||||
outdir: './built/boot',
|
||||
target: 'node22',
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
sourcemap: 'linked',
|
||||
packages: 'external',
|
||||
banner: {
|
||||
js: 'import { createRequire as topLevelCreateRequire } from "module";' +
|
||||
'import ___url___ from "url";' +
|
||||
'const require = topLevelCreateRequire(import.meta.url);' +
|
||||
'const __filename = ___url___.fileURLToPath(import.meta.url);' +
|
||||
'const __dirname = ___url___.fileURLToPath(new URL(".", import.meta.url));',
|
||||
},
|
||||
plugins: [
|
||||
externalIpaddrPlugin,
|
||||
resolveTsPathsPlugin,
|
||||
swcPlugin({
|
||||
jsc: {
|
||||
parser: {
|
||||
syntax: 'typescript',
|
||||
decorators: true,
|
||||
dynamicImport: true,
|
||||
},
|
||||
transform: {
|
||||
legacyDecorator: true,
|
||||
decoratorMetadata: true,
|
||||
},
|
||||
experimental: {
|
||||
keepImportAssertions: true,
|
||||
},
|
||||
baseUrl: join(_dirname, 'src'),
|
||||
paths: {
|
||||
'@/*': ['*'],
|
||||
},
|
||||
target: 'esnext',
|
||||
keepClassNames: true,
|
||||
},
|
||||
}),
|
||||
externalIpaddrPlugin,
|
||||
],
|
||||
// external: [
|
||||
// 'slacc-*',
|
||||
// 'class-transformer',
|
||||
// 'class-validator',
|
||||
// '@sentry/*',
|
||||
// '@nestjs/websockets/socket-module',
|
||||
// '@nestjs/microservices/microservices-module',
|
||||
// '@nestjs/microservices',
|
||||
// '@napi-rs/canvas-win32-x64-msvc',
|
||||
// 'mock-aws-s3',
|
||||
// 'aws-sdk',
|
||||
// 'nock',
|
||||
// 'sharp',
|
||||
// 'jsdom',
|
||||
// 're2',
|
||||
// '@napi-rs/canvas',
|
||||
// ],
|
||||
};
|
||||
|
||||
const args = process.argv.slice(2).map(arg => arg.toLowerCase());
|
||||
|
||||
if (!args.includes('--no-clean')) {
|
||||
fs.rmSync('./built', { recursive: true, force: true });
|
||||
}
|
||||
|
||||
//await minifyJsFiles(join(_dirname, 'node_modules'));
|
||||
|
||||
await minifyJsFiles(join(_dirname, '../../node_modules'));
|
||||
|
||||
await buildSrc();
|
||||
|
||||
async function buildSrc() {
|
||||
console.log(`[${_package.name}] start building...`);
|
||||
|
||||
await esbuild.build(options)
|
||||
.then(() => {
|
||||
console.log(`[${_package.name}] build succeeded.`);
|
||||
})
|
||||
.catch((err) => {
|
||||
process.stderr.write(err.stderr || err.message || err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
console.log(`[${_package.name}] finish building.`);
|
||||
}
|
||||
|
||||
async function minifyJsFile(fullPath) {
|
||||
if (!fullPath.includes('node_modules') || fullPath.includes('storybook') || fullPath.includes('tensorflow') || fullPath.includes('vite') || fullPath.includes('vue') || fullPath.includes('esbuild') || fullPath.includes('typescript') || fullPath.includes('css') || fullPath.includes('lint') || fullPath.includes('roll') || fullPath.includes('sass')) {
|
||||
console.log(`Skipped: ${fullPath}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = fs.readFileSync(fullPath, 'utf-8');
|
||||
if (data.includes('0 && (module.exports')) {
|
||||
console.log(`Skipped: ${fullPath}`);
|
||||
return;
|
||||
}
|
||||
//await esbuild.build({
|
||||
// entryPoints: [fullPath],
|
||||
// minifyWhitespace: true,
|
||||
// outdir: dirname(fullPath),
|
||||
// allowOverwrite: true,
|
||||
//});
|
||||
const result = await esbuild.transform(data, {
|
||||
minifyWhitespace: true,
|
||||
minifyIdentifiers: true,
|
||||
minifySyntax: false, // nestjsが壊れる
|
||||
treeShaking: false,
|
||||
});
|
||||
fs.writeFileSync(fullPath, result.code, 'utf-8');
|
||||
console.log(`Minified: ${fullPath}`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (err) {
|
||||
console.log(`Skipped (error): ${fullPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function minifyJsFiles(dir) {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await minifyJsFiles(fullPath);
|
||||
} else if (entry.isFile() && entry.name.endsWith('.js')) {
|
||||
await minifyJsFile(fullPath);
|
||||
} else {
|
||||
// resolve symbolic link
|
||||
const stats = fs.lstatSync(fullPath);
|
||||
if (stats.isSymbolicLink()) {
|
||||
const realPath = fs.realpathSync(fullPath);
|
||||
const realStats = fs.statSync(realPath);
|
||||
if (realStats.isDirectory()) {
|
||||
await minifyJsFiles(realPath);
|
||||
} else if (realStats.isFile() && realPath.endsWith('.js')) {
|
||||
await minifyJsFile(realPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
packages/backend/migration/1767169026317-birthday-index.js
Normal file
20
packages/backend/migration/1767169026317-birthday-index.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class BirthdayIndex1767169026317 {
|
||||
name = 'BirthdayIndex1767169026317'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_de22cd2b445eee31ae51cdbe99"`);
|
||||
await queryRunner.query(`CREATE OR REPLACE FUNCTION get_birthday_date(birthday TEXT) RETURNS SMALLINT AS $$ BEGIN RETURN CAST((SUBSTR(birthday, 6, 2) || SUBSTR(birthday, 9, 2)) AS SMALLINT); END; $$ LANGUAGE plpgsql IMMUTABLE;`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_USERPROFILE_BIRTHDAY_DATE" ON "user_profile" (get_birthday_date("birthday"))`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`CREATE INDEX "IDX_de22cd2b445eee31ae51cdbe99" ON "user_profile" (substr("birthday", 6, 5))`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_USERPROFILE_BIRTHDAY_DATE"`);
|
||||
await queryRunner.query(`DROP FUNCTION IF EXISTS get_birthday_date(birthday TEXT)`);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { loadConfig } from './built/config.js';
|
||||
import { entities } from './built/postgres.js';
|
||||
import { loadConfig } from './src-js/config.js';
|
||||
import { entities } from './src-js/postgres.js';
|
||||
|
||||
const isConcurrentIndexMigrationEnabled = process.env.MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY === '1';
|
||||
|
||||
|
||||
@@ -12,17 +12,17 @@
|
||||
"start:test": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./built/boot/entry.js",
|
||||
"migrate": "pnpm compile-config && pnpm typeorm migration:run -d ormconfig.js",
|
||||
"revert": "pnpm compile-config && pnpm typeorm migration:revert -d ormconfig.js",
|
||||
"cli": "pnpm compile-config && node ./built/boot/cli.js",
|
||||
"cli": "pnpm compile-config && node ./src-js/boot/cli.js",
|
||||
"check:connect": "pnpm compile-config && node ./scripts/check_connect.js",
|
||||
"compile-config": "node ./scripts/compile_config.js",
|
||||
"build": "swc src -d built -D --strip-leading-paths",
|
||||
"build": "swc src -d src-js -D --strip-leading-paths && node ./build.js",
|
||||
"build:test": "swc test-server -d built-test -D --config-file test-server/.swcrc --strip-leading-paths",
|
||||
"watch:swc": "swc src -d built -D -w --strip-leading-paths",
|
||||
"build:tsc": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json",
|
||||
"build:tsc": "tsgo -p tsconfig.json && tsc-alias -p tsconfig.json",
|
||||
"watch": "pnpm compile-config && node ./scripts/watch.mjs",
|
||||
"restart": "pnpm build && pnpm start",
|
||||
"dev": "pnpm compile-config && node ./scripts/dev.mjs",
|
||||
"typecheck": "tsc --noEmit && tsc -p test --noEmit && tsc -p test-federation --noEmit",
|
||||
"typecheck": "tsgo --noEmit && tsgo -p test --noEmit && tsgo -p test-federation --noEmit",
|
||||
"eslint": "eslint --quiet \"{src,test-federation}/**/*.ts\"",
|
||||
"lint": "pnpm typecheck && pnpm eslint",
|
||||
"jest": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.unit.cjs",
|
||||
@@ -41,20 +41,20 @@
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@swc/core-android-arm64": "1.3.11",
|
||||
"@swc/core-darwin-arm64": "1.15.3",
|
||||
"@swc/core-darwin-x64": "1.15.3",
|
||||
"@swc/core-darwin-arm64": "1.15.7",
|
||||
"@swc/core-darwin-x64": "1.15.7",
|
||||
"@swc/core-freebsd-x64": "1.3.11",
|
||||
"@swc/core-linux-arm-gnueabihf": "1.15.3",
|
||||
"@swc/core-linux-arm64-gnu": "1.15.3",
|
||||
"@swc/core-linux-arm64-musl": "1.15.3",
|
||||
"@swc/core-linux-x64-gnu": "1.15.3",
|
||||
"@swc/core-linux-x64-musl": "1.15.3",
|
||||
"@swc/core-win32-arm64-msvc": "1.15.3",
|
||||
"@swc/core-win32-ia32-msvc": "1.15.3",
|
||||
"@swc/core-win32-x64-msvc": "1.15.3",
|
||||
"@swc/core-linux-arm-gnueabihf": "1.15.7",
|
||||
"@swc/core-linux-arm64-gnu": "1.15.7",
|
||||
"@swc/core-linux-arm64-musl": "1.15.7",
|
||||
"@swc/core-linux-x64-gnu": "1.15.7",
|
||||
"@swc/core-linux-x64-musl": "1.15.7",
|
||||
"@swc/core-win32-arm64-msvc": "1.15.7",
|
||||
"@swc/core-win32-ia32-msvc": "1.15.7",
|
||||
"@swc/core-win32-x64-msvc": "1.15.7",
|
||||
"@tensorflow/tfjs": "4.22.0",
|
||||
"@tensorflow/tfjs-node": "4.22.0",
|
||||
"bufferutil": "4.0.9",
|
||||
"bufferutil": "4.1.0",
|
||||
"slacc-android-arm-eabi": "0.0.10",
|
||||
"slacc-android-arm64": "0.0.10",
|
||||
"slacc-darwin-arm64": "0.0.10",
|
||||
@@ -68,11 +68,11 @@
|
||||
"slacc-linux-x64-musl": "0.0.10",
|
||||
"slacc-win32-arm64-msvc": "0.0.10",
|
||||
"slacc-win32-x64-msvc": "0.0.10",
|
||||
"utf-8-validate": "6.0.5"
|
||||
"utf-8-validate": "6.0.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.948.0",
|
||||
"@aws-sdk/lib-storage": "3.948.0",
|
||||
"@aws-sdk/client-s3": "3.958.0",
|
||||
"@aws-sdk/lib-storage": "3.958.0",
|
||||
"@discordapp/twemoji": "16.0.1",
|
||||
"@fastify/accepts": "5.0.4",
|
||||
"@fastify/cors": "11.2.0",
|
||||
@@ -83,18 +83,18 @@
|
||||
"@kitajs/html": "4.2.11",
|
||||
"@misskey-dev/sharp-read-bmp": "1.2.0",
|
||||
"@misskey-dev/summaly": "5.2.5",
|
||||
"@napi-rs/canvas": "0.1.84",
|
||||
"@nestjs/common": "11.1.9",
|
||||
"@nestjs/core": "11.1.9",
|
||||
"@nestjs/testing": "11.1.9",
|
||||
"@napi-rs/canvas": "0.1.87",
|
||||
"@nestjs/common": "11.1.10",
|
||||
"@nestjs/core": "11.1.10",
|
||||
"@nestjs/testing": "11.1.10",
|
||||
"@peertube/http-signature": "1.7.0",
|
||||
"@sentry/node": "10.29.0",
|
||||
"@sentry/profiling-node": "10.29.0",
|
||||
"@sentry/node": "10.32.1",
|
||||
"@sentry/profiling-node": "10.32.1",
|
||||
"@simplewebauthn/server": "13.2.2",
|
||||
"@sinonjs/fake-timers": "15.0.0",
|
||||
"@smithy/node-http-handler": "4.4.5",
|
||||
"@sinonjs/fake-timers": "15.1.0",
|
||||
"@smithy/node-http-handler": "4.4.7",
|
||||
"@swc/cli": "0.7.9",
|
||||
"@swc/core": "1.15.3",
|
||||
"@swc/core": "1.15.7",
|
||||
"@twemoji/parser": "16.0.0",
|
||||
"@types/redis-info": "3.0.3",
|
||||
"accepts": "1.3.8",
|
||||
@@ -104,7 +104,7 @@
|
||||
"bcryptjs": "3.0.3",
|
||||
"blurhash": "2.0.5",
|
||||
"body-parser": "2.2.1",
|
||||
"bullmq": "5.65.1",
|
||||
"bullmq": "5.66.3",
|
||||
"cacheable-lookup": "7.0.0",
|
||||
"chalk": "5.6.2",
|
||||
"chalk-template": "1.1.2",
|
||||
@@ -116,7 +116,7 @@
|
||||
"fastify": "5.6.2",
|
||||
"fastify-raw-body": "5.0.0",
|
||||
"feed": "5.1.0",
|
||||
"file-type": "21.1.1",
|
||||
"file-type": "21.2.0",
|
||||
"fluent-ffmpeg": "2.1.3",
|
||||
"form-data": "4.0.5",
|
||||
"got": "14.6.5",
|
||||
@@ -140,7 +140,7 @@
|
||||
"nested-property": "4.0.0",
|
||||
"node-fetch": "3.3.2",
|
||||
"node-html-parser": "7.0.1",
|
||||
"nodemailer": "7.0.11",
|
||||
"nodemailer": "7.0.12",
|
||||
"nsfwjs": "4.2.0",
|
||||
"oauth2orize": "1.12.0",
|
||||
"oauth2orize-pkce": "0.1.2",
|
||||
@@ -153,7 +153,7 @@
|
||||
"qrcode": "1.5.4",
|
||||
"random-seed": "0.3.0",
|
||||
"ratelimiter": "3.4.1",
|
||||
"re2": "1.22.3",
|
||||
"re2": "1.23.0",
|
||||
"redis-info": "3.1.0",
|
||||
"reflect-metadata": "0.2.2",
|
||||
"rename": "1.0.4",
|
||||
@@ -166,12 +166,11 @@
|
||||
"slacc": "0.0.10",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"stringz": "2.1.0",
|
||||
"systeminformation": "5.27.14",
|
||||
"systeminformation": "5.28.1",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tmp": "0.2.5",
|
||||
"tsc-alias": "1.8.16",
|
||||
"typeorm": "0.3.28",
|
||||
"typescript": "5.9.3",
|
||||
"ulid": "3.0.2",
|
||||
"vary": "1.1.2",
|
||||
"web-push": "3.6.7",
|
||||
@@ -181,8 +180,8 @@
|
||||
"devDependencies": {
|
||||
"@jest/globals": "29.7.0",
|
||||
"@kitajs/ts-html-plugin": "4.1.3",
|
||||
"@nestjs/platform-express": "11.1.9",
|
||||
"@sentry/vue": "10.29.0",
|
||||
"@nestjs/platform-express": "11.1.10",
|
||||
"@sentry/vue": "10.32.1",
|
||||
"@simplewebauthn/types": "12.0.0",
|
||||
"@swc/jest": "0.2.39",
|
||||
"@types/accepts": "1.3.7",
|
||||
@@ -196,11 +195,11 @@
|
||||
"@types/jsonld": "1.5.15",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/ms": "2.1.0",
|
||||
"@types/node": "24.10.2",
|
||||
"@types/node": "24.10.4",
|
||||
"@types/nodemailer": "7.0.4",
|
||||
"@types/oauth2orize": "1.11.5",
|
||||
"@types/oauth2orize-pkce": "0.1.2",
|
||||
"@types/pg": "8.15.6",
|
||||
"@types/pg": "8.16.0",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/random-seed": "0.3.5",
|
||||
"@types/ratelimiter": "3.4.6",
|
||||
@@ -215,11 +214,12 @@
|
||||
"@types/vary": "1.1.3",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.49.0",
|
||||
"@typescript-eslint/parser": "8.49.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.50.1",
|
||||
"@typescript-eslint/parser": "8.50.1",
|
||||
"aws-sdk-client-mock": "4.1.0",
|
||||
"cbor": "10.0.11",
|
||||
"cross-env": "10.1.0",
|
||||
"esbuild-plugin-swc": "1.0.1",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"execa": "9.6.1",
|
||||
"fkill": "10.0.1",
|
||||
@@ -230,6 +230,6 @@
|
||||
"pid-port": "2.0.0",
|
||||
"simple-oauth2": "5.1.0",
|
||||
"supertest": "7.1.4",
|
||||
"vite": "7.2.7"
|
||||
"vite": "7.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
*/
|
||||
|
||||
import Redis from 'ioredis';
|
||||
import { loadConfig } from '../built/config.js';
|
||||
import { createPostgresDataSource } from '../built/postgres.js';
|
||||
import { loadConfig } from '../src-js/config.js';
|
||||
import { createPostgresDataSource } from '../src-js/postgres.js';
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
@@ -28,10 +28,8 @@ async function connectToRedis(redisOptions) {
|
||||
try {
|
||||
await redis.connect();
|
||||
resolve();
|
||||
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
|
||||
} finally {
|
||||
redis.disconnect(false);
|
||||
}
|
||||
@@ -50,7 +48,7 @@ const promises = Array
|
||||
]))
|
||||
.map(connectToRedis)
|
||||
.concat([
|
||||
connectToPostgres()
|
||||
connectToPostgres(),
|
||||
]);
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { writeFileSync, existsSync } from 'node:fs';
|
||||
import { execa } from 'execa';
|
||||
import { writeFileSync, existsSync } from "node:fs";
|
||||
|
||||
async function main() {
|
||||
if (!process.argv.includes('--no-build')) {
|
||||
@@ -19,10 +19,10 @@ async function main() {
|
||||
}
|
||||
|
||||
/** @type {import('../src/config.js')} */
|
||||
const { loadConfig } = await import('../built/config.js');
|
||||
const { loadConfig } = await import('../src-js/config.js');
|
||||
|
||||
/** @type {import('../src/server/api/openapi/gen-spec.js')} */
|
||||
const { genOpenapiSpec } = await import('../built/server/api/openapi/gen-spec.js');
|
||||
const { genOpenapiSpec } = await import('../src-js/server/api/openapi/gen-spec.js');
|
||||
|
||||
const config = loadConfig();
|
||||
const spec = genOpenapiSpec(config, true);
|
||||
|
||||
@@ -21,7 +21,7 @@ import { execa } from 'execa';
|
||||
});
|
||||
}, 3000);
|
||||
|
||||
execa('tsc', ['-w', '-p', 'tsconfig.json'], {
|
||||
execa('tsgo', ['-w', '-p', 'tsconfig.json'], {
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import cluster from 'node:cluster';
|
||||
import chalk from 'chalk';
|
||||
@@ -17,20 +15,15 @@ import { showMachineInfo } from '@/misc/show-machine-info.js';
|
||||
import { envOption } from '@/env.js';
|
||||
import { jobQueue, server } from './common.js';
|
||||
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
|
||||
const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../../built/meta.json`, 'utf-8'));
|
||||
|
||||
const logger = new Logger('core', 'cyan');
|
||||
const bootLogger = logger.createSubLogger('boot', 'magenta');
|
||||
|
||||
const themeColor = chalk.hex('#86b300');
|
||||
|
||||
function greet() {
|
||||
function greet(props: { version: string }) {
|
||||
if (!envOption.quiet) {
|
||||
//#region Misskey logo
|
||||
const v = `v${meta.version}`;
|
||||
const v = `v${props.version}`;
|
||||
console.log(themeColor(' _____ _ _ '));
|
||||
console.log(themeColor(' | |_|___ ___| |_ ___ _ _ '));
|
||||
console.log(themeColor(' | | | | |_ -|_ -| \'_| -_| | |'));
|
||||
@@ -46,7 +39,7 @@ function greet() {
|
||||
}
|
||||
|
||||
bootLogger.info('Welcome to Misskey!');
|
||||
bootLogger.info(`Misskey v${meta.version}`, null, true);
|
||||
bootLogger.info(`Misskey v${props.version}`, null, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,11 +50,11 @@ export async function masterMain() {
|
||||
|
||||
// initialize app
|
||||
try {
|
||||
greet();
|
||||
config = loadConfigBoot();
|
||||
greet({ version: config.version });
|
||||
showEnvironment();
|
||||
await showMachineInfo(bootLogger);
|
||||
showNodejsVersion();
|
||||
config = loadConfigBoot();
|
||||
//await connectDb();
|
||||
if (config.pidFile) fs.writeFileSync(config.pidFile, process.pid.toString());
|
||||
} catch (e) {
|
||||
|
||||
@@ -219,24 +219,42 @@ export type FulltextSearchProvider = 'sqlLike' | 'sqlPgroonga' | 'meilisearch';
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
|
||||
const compiledConfigFilePathForTest = resolve(_dirname, '../../../built/._config_.json');
|
||||
/** Path of repository root directory */
|
||||
let rootDir = _dirname;
|
||||
// 見つかるまで上に遡る
|
||||
while (!fs.existsSync(resolve(rootDir, 'packages'))) {
|
||||
const parentDir = dirname(rootDir);
|
||||
if (parentDir === rootDir) {
|
||||
throw new Error('Cannot find root directory');
|
||||
}
|
||||
rootDir = parentDir;
|
||||
}
|
||||
|
||||
export const compiledConfigFilePath = fs.existsSync(compiledConfigFilePathForTest) ? compiledConfigFilePathForTest : resolve(_dirname, '../../../built/.config.json');
|
||||
/** Path of configuration directory */
|
||||
const configDir = resolve(rootDir, '.config');
|
||||
/** Path of built directory */
|
||||
const projectBuiltDir = resolve(rootDir, 'built');
|
||||
|
||||
const compiledConfigFilePathForTest = resolve(projectBuiltDir, '._config_.json');
|
||||
|
||||
export const compiledConfigFilePath = fs.existsSync(compiledConfigFilePathForTest)
|
||||
? compiledConfigFilePathForTest
|
||||
: resolve(projectBuiltDir, '.config.json');
|
||||
|
||||
export function loadConfig(): Config {
|
||||
if (!fs.existsSync(compiledConfigFilePath)) {
|
||||
throw new Error('Compiled configuration file not found. Try running \'pnpm compile-config\'.');
|
||||
}
|
||||
|
||||
const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/meta.json`, 'utf-8'));
|
||||
const meta = JSON.parse(fs.readFileSync(resolve(projectBuiltDir, 'meta.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 frontendManifestExists = fs.existsSync(resolve(projectBuiltDir, '_frontend_vite_/manifest.json'));
|
||||
const frontendEmbedManifestExists = fs.existsSync(resolve(projectBuiltDir, '_frontend_embed_vite_/manifest.json'));
|
||||
const frontendManifest = frontendManifestExists ?
|
||||
JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_vite_/manifest.json`, 'utf-8'))
|
||||
JSON.parse(fs.readFileSync(resolve(projectBuiltDir, '_frontend_vite_/manifest.json'), 'utf-8'))
|
||||
: { 'src/_boot_.ts': { file: null } };
|
||||
const frontendEmbedManifest = frontendEmbedManifestExists ?
|
||||
JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_embed_vite_/manifest.json`, 'utf-8'))
|
||||
JSON.parse(fs.readFileSync(resolve(projectBuiltDir, '_frontend_embed_vite_/manifest.json'), 'utf-8'))
|
||||
: { 'src/boot.ts': { file: null } };
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(compiledConfigFilePath, 'utf-8')) as Source;
|
||||
|
||||
@@ -141,7 +141,7 @@ import { ApLoggerService } from './activitypub/ApLoggerService.js';
|
||||
import { ApMfmService } from './activitypub/ApMfmService.js';
|
||||
import { ApRendererService } from './activitypub/ApRendererService.js';
|
||||
import { ApRequestService } from './activitypub/ApRequestService.js';
|
||||
import { ApResolverService } from './activitypub/ApResolverService.js';
|
||||
import { ApResolverService, Resolver } from './activitypub/ApResolverService.js';
|
||||
import { JsonLdService } from './activitypub/JsonLdService.js';
|
||||
import { RemoteLoggerService } from './RemoteLoggerService.js';
|
||||
import { RemoteUserResolveService } from './RemoteUserResolveService.js';
|
||||
@@ -447,6 +447,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
ApRendererService,
|
||||
ApRequestService,
|
||||
ApResolverService,
|
||||
Resolver,
|
||||
JsonLdService,
|
||||
RemoteLoggerService,
|
||||
RemoteUserResolveService,
|
||||
@@ -745,6 +746,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
ApRendererService,
|
||||
ApRequestService,
|
||||
ApResolverService,
|
||||
Resolver,
|
||||
JsonLdService,
|
||||
RemoteLoggerService,
|
||||
RemoteUserResolveService,
|
||||
|
||||
@@ -95,7 +95,7 @@ export class ApInboxService {
|
||||
if (isCollectionOrOrderedCollection(activity)) {
|
||||
const results = [] as [string, string | void][];
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
resolver ??= this.apResolverService.createResolver();
|
||||
resolver ??= await this.apResolverService.createResolver();
|
||||
|
||||
const items = toArray(isCollection(activity) ? activity.items : activity.orderedItems);
|
||||
if (items.length >= resolver.getRecursionLimit()) {
|
||||
@@ -221,7 +221,7 @@ export class ApInboxService {
|
||||
this.logger.info(`Accept: ${uri}`);
|
||||
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
resolver ??= this.apResolverService.createResolver();
|
||||
resolver ??= await this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(activity.object).catch(err => {
|
||||
this.logger.error(`Resolution failed: ${err}`);
|
||||
@@ -284,7 +284,7 @@ export class ApInboxService {
|
||||
this.logger.info(`Announce: ${uri}`);
|
||||
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
resolver ??= this.apResolverService.createResolver();
|
||||
resolver ??= await this.apResolverService.createResolver();
|
||||
|
||||
if (!activity.object) return 'skip: activity has no object property';
|
||||
const targetUri = getApId(activity.object);
|
||||
@@ -406,7 +406,7 @@ export class ApInboxService {
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
resolver ??= this.apResolverService.createResolver();
|
||||
resolver ??= await this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(activity.object).catch(e => {
|
||||
this.logger.error(`Resolution failed: ${e}`);
|
||||
@@ -575,7 +575,7 @@ export class ApInboxService {
|
||||
this.logger.info(`Reject: ${uri}`);
|
||||
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
resolver ??= this.apResolverService.createResolver();
|
||||
resolver ??= await this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(activity.object).catch(e => {
|
||||
this.logger.error(`Resolution failed: ${e}`);
|
||||
@@ -642,7 +642,7 @@ export class ApInboxService {
|
||||
this.logger.info(`Undo: ${uri}`);
|
||||
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
resolver ??= this.apResolverService.createResolver();
|
||||
resolver ??= await this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(activity.object).catch(e => {
|
||||
this.logger.error(`Resolution failed: ${e}`);
|
||||
@@ -774,7 +774,7 @@ export class ApInboxService {
|
||||
this.logger.debug('Update');
|
||||
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
resolver ??= this.apResolverService.createResolver();
|
||||
resolver ??= await this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(activity.object).catch(e => {
|
||||
this.logger.error(`Resolution failed: ${e}`);
|
||||
|
||||
@@ -3,10 +3,17 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, Scope } from '@nestjs/common';
|
||||
import { IsNull, Not } from 'typeorm';
|
||||
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
||||
import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js';
|
||||
import type {
|
||||
FollowRequestsRepository,
|
||||
MiMeta,
|
||||
NoteReactionsRepository,
|
||||
NotesRepository,
|
||||
PollsRepository,
|
||||
UsersRepository
|
||||
} from '@/models/_.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
@@ -16,26 +23,43 @@ import { LoggerService } from '@/core/LoggerService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { SystemAccountService } from '@/core/SystemAccountService.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import type { ICollection, IObject, IOrderedCollection } from './type.js';
|
||||
import { isCollectionOrOrderedCollection } from './type.js';
|
||||
import { ApDbResolverService } from './ApDbResolverService.js';
|
||||
import { ApRendererService } from './ApRendererService.js';
|
||||
import { ApRequestService } from './ApRequestService.js';
|
||||
import { FetchAllowSoftFailMask } from './misc/check-against-url.js';
|
||||
import type { IObject, ICollection, IOrderedCollection } from './type.js';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
|
||||
@Injectable({ scope: Scope.TRANSIENT })
|
||||
export class Resolver {
|
||||
private history: Set<string>;
|
||||
private user?: MiLocalUser;
|
||||
private logger: Logger;
|
||||
private recursionLimit = 256;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.pollsRepository)
|
||||
private pollsRepository: PollsRepository,
|
||||
|
||||
@Inject(DI.noteReactionsRepository)
|
||||
private noteReactionsRepository: NoteReactionsRepository,
|
||||
|
||||
@Inject(DI.followRequestsRepository)
|
||||
private followRequestsRepository: FollowRequestsRepository,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
private systemAccountService: SystemAccountService,
|
||||
private apRequestService: ApRequestService,
|
||||
@@ -43,7 +67,6 @@ export class Resolver {
|
||||
private apRendererService: ApRendererService,
|
||||
private apDbResolverService: ApDbResolverService,
|
||||
private loggerService: LoggerService,
|
||||
private recursionLimit = 256,
|
||||
) {
|
||||
this.history = new Set();
|
||||
this.logger = this.loggerService.getLogger('ap-resolve');
|
||||
@@ -180,54 +203,12 @@ export class Resolver {
|
||||
@Injectable()
|
||||
export class ApResolverService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.pollsRepository)
|
||||
private pollsRepository: PollsRepository,
|
||||
|
||||
@Inject(DI.noteReactionsRepository)
|
||||
private noteReactionsRepository: NoteReactionsRepository,
|
||||
|
||||
@Inject(DI.followRequestsRepository)
|
||||
private followRequestsRepository: FollowRequestsRepository,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
private systemAccountService: SystemAccountService,
|
||||
private apRequestService: ApRequestService,
|
||||
private httpRequestService: HttpRequestService,
|
||||
private apRendererService: ApRendererService,
|
||||
private apDbResolverService: ApDbResolverService,
|
||||
private loggerService: LoggerService,
|
||||
private moduleRef: ModuleRef,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public createResolver(): Resolver {
|
||||
return new Resolver(
|
||||
this.config,
|
||||
this.meta,
|
||||
this.usersRepository,
|
||||
this.notesRepository,
|
||||
this.pollsRepository,
|
||||
this.noteReactionsRepository,
|
||||
this.followRequestsRepository,
|
||||
this.utilityService,
|
||||
this.systemAccountService,
|
||||
this.apRequestService,
|
||||
this.httpRequestService,
|
||||
this.apRendererService,
|
||||
this.apDbResolverService,
|
||||
this.loggerService,
|
||||
);
|
||||
public async createResolver(): Promise<Resolver> {
|
||||
return await this.moduleRef.create(Resolver);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export class ApImageService {
|
||||
throw new Error('actor has been suspended');
|
||||
}
|
||||
|
||||
const image = await this.apResolverService.createResolver().resolve(value);
|
||||
const image = await (await this.apResolverService.createResolver()).resolve(value);
|
||||
|
||||
if (!isDocument(image)) return null;
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ export class ApNoteService {
|
||||
@bindThis
|
||||
public async createNote(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver, silent = false): Promise<MiNote | null> {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||
if (resolver == null) resolver = await this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(value);
|
||||
|
||||
|
||||
@@ -310,7 +310,7 @@ export class ApPersonService implements OnModuleInit {
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||
if (resolver == null) resolver = await this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(uri);
|
||||
if (object.id == null) throw new Error('invalid object.id: ' + object.id);
|
||||
@@ -500,7 +500,7 @@ export class ApPersonService implements OnModuleInit {
|
||||
//#endregion
|
||||
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||
if (resolver == null) resolver = await this.apResolverService.createResolver();
|
||||
|
||||
const object = hint ?? await resolver.resolve(uri);
|
||||
|
||||
@@ -678,7 +678,7 @@ export class ApPersonService implements OnModuleInit {
|
||||
|
||||
// リモートサーバーからフェッチしてきて登録
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||
if (resolver == null) resolver = await this.apResolverService.createResolver();
|
||||
return await this.createPerson(uri, resolver);
|
||||
}
|
||||
|
||||
@@ -707,7 +707,7 @@ export class ApPersonService implements OnModuleInit {
|
||||
|
||||
this.logger.info(`Updating the featured: ${user.uri}`);
|
||||
|
||||
const _resolver = resolver ?? this.apResolverService.createResolver();
|
||||
const _resolver = resolver ?? await this.apResolverService.createResolver();
|
||||
|
||||
// Resolve to (Ordered)Collection Object
|
||||
const collection = await _resolver.resolveCollection(user.featured);
|
||||
|
||||
@@ -45,7 +45,7 @@ export class ApQuestionService {
|
||||
@bindThis
|
||||
public async extractPollFromQuestion(source: string | IObject, resolver?: Resolver): Promise<IPoll> {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||
if (resolver == null) resolver = await this.apResolverService.createResolver();
|
||||
|
||||
const question = await resolver.resolve(source);
|
||||
if (!isQuestion(question)) throw new Error('invalid type');
|
||||
@@ -91,7 +91,7 @@ export class ApQuestionService {
|
||||
|
||||
// resolve new Question object
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||
if (resolver == null) resolver = await this.apResolverService.createResolver();
|
||||
const question = await resolver.resolve(value);
|
||||
this.logger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`);
|
||||
|
||||
|
||||
@@ -720,7 +720,7 @@ export class UserEntityService implements OnModuleInit {
|
||||
me,
|
||||
{
|
||||
...options,
|
||||
userProfile: profilesMap.get(u.id),
|
||||
userProfile: profilesMap?.get(u.id),
|
||||
userRelations: userRelations,
|
||||
userMemos: userMemos,
|
||||
pinNotes: pinNotes,
|
||||
|
||||
@@ -13,7 +13,6 @@ import { NodeinfoServerService } from './NodeinfoServerService.js';
|
||||
import { ServerService } from './ServerService.js';
|
||||
import { WellKnownServerService } from './WellKnownServerService.js';
|
||||
import { GetterService } from './api/GetterService.js';
|
||||
import { ChannelsService } from './api/stream/ChannelsService.js';
|
||||
import { ActivityPubServerService } from './ActivityPubServerService.js';
|
||||
import { ApiLoggerService } from './api/ApiLoggerService.js';
|
||||
import { ApiServerService } from './api/ApiServerService.js';
|
||||
@@ -31,24 +30,25 @@ import { UrlPreviewService } from './web/UrlPreviewService.js';
|
||||
import { ClientLoggerService } from './web/ClientLoggerService.js';
|
||||
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
|
||||
|
||||
import { MainChannelService } from './api/stream/channels/main.js';
|
||||
import { AdminChannelService } from './api/stream/channels/admin.js';
|
||||
import { AntennaChannelService } from './api/stream/channels/antenna.js';
|
||||
import { ChannelChannelService } from './api/stream/channels/channel.js';
|
||||
import { DriveChannelService } from './api/stream/channels/drive.js';
|
||||
import { GlobalTimelineChannelService } from './api/stream/channels/global-timeline.js';
|
||||
import { HashtagChannelService } from './api/stream/channels/hashtag.js';
|
||||
import { HomeTimelineChannelService } from './api/stream/channels/home-timeline.js';
|
||||
import { HybridTimelineChannelService } from './api/stream/channels/hybrid-timeline.js';
|
||||
import { LocalTimelineChannelService } from './api/stream/channels/local-timeline.js';
|
||||
import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js';
|
||||
import { ServerStatsChannelService } from './api/stream/channels/server-stats.js';
|
||||
import { UserListChannelService } from './api/stream/channels/user-list.js';
|
||||
import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
|
||||
import { ChatUserChannelService } from './api/stream/channels/chat-user.js';
|
||||
import { ChatRoomChannelService } from './api/stream/channels/chat-room.js';
|
||||
import { ReversiChannelService } from './api/stream/channels/reversi.js';
|
||||
import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js';
|
||||
import MainStreamConnection from '@/server/api/stream/Connection.js';
|
||||
import { MainChannel } from './api/stream/channels/main.js';
|
||||
import { AdminChannel } from './api/stream/channels/admin.js';
|
||||
import { AntennaChannel } from './api/stream/channels/antenna.js';
|
||||
import { ChannelChannel } from './api/stream/channels/channel.js';
|
||||
import { DriveChannel } from './api/stream/channels/drive.js';
|
||||
import { GlobalTimelineChannel } from './api/stream/channels/global-timeline.js';
|
||||
import { HashtagChannel } from './api/stream/channels/hashtag.js';
|
||||
import { HomeTimelineChannel } from './api/stream/channels/home-timeline.js';
|
||||
import { HybridTimelineChannel } from './api/stream/channels/hybrid-timeline.js';
|
||||
import { LocalTimelineChannel } from './api/stream/channels/local-timeline.js';
|
||||
import { QueueStatsChannel } from './api/stream/channels/queue-stats.js';
|
||||
import { ServerStatsChannel } from './api/stream/channels/server-stats.js';
|
||||
import { UserListChannel } from './api/stream/channels/user-list.js';
|
||||
import { RoleTimelineChannel } from './api/stream/channels/role-timeline.js';
|
||||
import { ChatUserChannel } from './api/stream/channels/chat-user.js';
|
||||
import { ChatRoomChannel } from './api/stream/channels/chat-room.js';
|
||||
import { ReversiChannel } from './api/stream/channels/reversi.js';
|
||||
import { ReversiGameChannel } from './api/stream/channels/reversi-game.js';
|
||||
import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js';
|
||||
|
||||
@Module({
|
||||
@@ -69,7 +69,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
|
||||
ServerService,
|
||||
WellKnownServerService,
|
||||
GetterService,
|
||||
ChannelsService,
|
||||
MainStreamConnection,
|
||||
ApiCallService,
|
||||
ApiLoggerService,
|
||||
ApiServerService,
|
||||
@@ -80,24 +80,24 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
|
||||
SigninService,
|
||||
SignupApiService,
|
||||
StreamingApiServerService,
|
||||
MainChannelService,
|
||||
AdminChannelService,
|
||||
AntennaChannelService,
|
||||
ChannelChannelService,
|
||||
DriveChannelService,
|
||||
GlobalTimelineChannelService,
|
||||
HashtagChannelService,
|
||||
RoleTimelineChannelService,
|
||||
ChatUserChannelService,
|
||||
ChatRoomChannelService,
|
||||
ReversiChannelService,
|
||||
ReversiGameChannelService,
|
||||
HomeTimelineChannelService,
|
||||
HybridTimelineChannelService,
|
||||
LocalTimelineChannelService,
|
||||
QueueStatsChannelService,
|
||||
ServerStatsChannelService,
|
||||
UserListChannelService,
|
||||
MainChannel,
|
||||
AdminChannel,
|
||||
AntennaChannel,
|
||||
ChannelChannel,
|
||||
DriveChannel,
|
||||
GlobalTimelineChannel,
|
||||
HashtagChannel,
|
||||
RoleTimelineChannel,
|
||||
ChatUserChannel,
|
||||
ChatRoomChannel,
|
||||
ReversiChannel,
|
||||
ReversiGameChannel,
|
||||
HomeTimelineChannel,
|
||||
HybridTimelineChannel,
|
||||
LocalTimelineChannel,
|
||||
QueueStatsChannel,
|
||||
ServerStatsChannel,
|
||||
UserListChannel,
|
||||
OpenApiServerService,
|
||||
OAuth2ProviderService,
|
||||
],
|
||||
|
||||
@@ -8,18 +8,14 @@ import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import * as WebSocket from 'ws';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { UsersRepository, MiAccessToken } from '@/models/_.js';
|
||||
import { NotificationService } from '@/core/NotificationService.js';
|
||||
import type { MiAccessToken } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { MiLocalUser } from '@/models/User.js';
|
||||
import { UserService } from '@/core/UserService.js';
|
||||
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
|
||||
import MainStreamConnection from './stream/Connection.js';
|
||||
import { ChannelsService } from './stream/ChannelsService.js';
|
||||
import MainStreamConnection, { ConnectionRequest } from './stream/Connection.js';
|
||||
import type * as http from 'node:http';
|
||||
import { ContextIdFactory, ModuleRef } from '@nestjs/core';
|
||||
|
||||
@Injectable()
|
||||
export class StreamingApiServerService {
|
||||
@@ -31,16 +27,9 @@ export class StreamingApiServerService {
|
||||
@Inject(DI.redisForSub)
|
||||
private redisForSub: Redis.Redis,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
private cacheService: CacheService,
|
||||
private moduleRef: ModuleRef,
|
||||
private authenticateService: AuthenticateService,
|
||||
private channelsService: ChannelsService,
|
||||
private notificationService: NotificationService,
|
||||
private usersService: UserService,
|
||||
private channelFollowingService: ChannelFollowingService,
|
||||
private channelMutingService: ChannelMutingService,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -94,14 +83,12 @@ export class StreamingApiServerService {
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = new MainStreamConnection(
|
||||
this.channelsService,
|
||||
this.notificationService,
|
||||
this.cacheService,
|
||||
this.channelFollowingService,
|
||||
this.channelMutingService,
|
||||
user, app,
|
||||
);
|
||||
const contextId = ContextIdFactory.create();
|
||||
this.moduleRef.registerRequestByContextId<ConnectionRequest>({
|
||||
user,
|
||||
token: app,
|
||||
}, contextId);
|
||||
const stream = await this.moduleRef.create(MainStreamConnection, contextId);
|
||||
|
||||
await stream.init();
|
||||
|
||||
|
||||
@@ -391,6 +391,7 @@ export * as 'users/featured-notes' from './endpoints/users/featured-notes.js';
|
||||
export * as 'users/flashs' from './endpoints/users/flashs.js';
|
||||
export * as 'users/followers' from './endpoints/users/followers.js';
|
||||
export * as 'users/following' from './endpoints/users/following.js';
|
||||
export * as 'users/get-following-birthday-users' from './endpoints/users/get-following-birthday-users.js';
|
||||
export * as 'users/gallery/posts' from './endpoints/users/gallery/posts.js';
|
||||
export * as 'users/get-frequently-replied-users' from './endpoints/users/get-frequently-replied-users.js';
|
||||
export * as 'users/lists/create' from './endpoints/users/lists/create.js';
|
||||
|
||||
@@ -43,7 +43,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
private apResolverService: ApResolverService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
const resolver = await this.apResolverService.createResolver();
|
||||
const object = await resolver.resolve(ps.uri);
|
||||
return object;
|
||||
});
|
||||
|
||||
@@ -148,7 +148,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
if (this.utilityService.isSelfHost(host)) return null;
|
||||
|
||||
// リモートから一旦オブジェクトフェッチ
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
const resolver = await this.apResolverService.createResolver();
|
||||
// allow ap/show exclusively to lookup URLs that are cross-origin or non-canonical (like https://alice.example.com/@bob@bob.example.com -> https://bob.example.com/@bob)
|
||||
const object = await resolver.resolve(uri, FetchAllowSoftFailMask.CrossOrigin | FetchAllowSoftFailMask.NonCanonicalId).catch((err) => {
|
||||
if (err instanceof IdentifiableError) {
|
||||
|
||||
@@ -86,7 +86,7 @@ export const paramDef = {
|
||||
sinceDate: { type: 'integer' },
|
||||
untilDate: { type: 'integer' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
birthday: { ...birthdaySchema, nullable: true },
|
||||
birthday: { ...birthdaySchema, nullable: true, description: '@deprecated use get-following-birthday-users instead.' },
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -146,14 +146,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
.andWhere('following.followerId = :userId', { userId: user.id })
|
||||
.innerJoinAndSelect('following.followee', 'followee');
|
||||
|
||||
// @deprecated use get-following-birthday-users instead.
|
||||
if (ps.birthday) {
|
||||
try {
|
||||
const birthday = ps.birthday.substring(5, 10);
|
||||
const birthdayUserQuery = this.userProfilesRepository.createQueryBuilder('user_profile');
|
||||
birthdayUserQuery.select('user_profile.userId')
|
||||
.where(`SUBSTR(user_profile.birthday, 6, 5) = '${birthday}'`);
|
||||
query.innerJoin(this.userProfilesRepository.metadata.targetName, 'followeeProfile', 'followeeProfile.userId = following.followeeId');
|
||||
|
||||
query.andWhere(`following.followeeId IN (${ birthdayUserQuery.getQuery() })`);
|
||||
try {
|
||||
const birthday = ps.birthday.split('-');
|
||||
birthday.shift(); // 年の部分を削除
|
||||
// なぜか get_birthday_date() = :birthday だとインデックスが効かないので、BETWEEN で対応
|
||||
query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :birthday AND :birthday', { birthday: parseInt(birthday.join('')) });
|
||||
} catch (err) {
|
||||
throw new ApiError(meta.errors.birthdayInvalid);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Brackets } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type {
|
||||
FollowingsRepository,
|
||||
UserProfilesRepository,
|
||||
} from '@/models/_.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['users'],
|
||||
|
||||
requireCredential: true,
|
||||
kind: 'read:account',
|
||||
|
||||
description: 'Find users who have a birthday on the specified range.',
|
||||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'misskey:id',
|
||||
},
|
||||
birthday: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
user: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'UserLite',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
offset: { type: 'integer', default: 0 },
|
||||
birthday: {
|
||||
oneOf: [{
|
||||
type: 'object',
|
||||
properties: {
|
||||
month: { type: 'integer', minimum: 1, maximum: 12 },
|
||||
day: { type: 'integer', minimum: 1, maximum: 31 },
|
||||
},
|
||||
required: ['month', 'day'],
|
||||
}, {
|
||||
type: 'object',
|
||||
properties: {
|
||||
begin: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
month: { type: 'integer', minimum: 1, maximum: 12 },
|
||||
day: { type: 'integer', minimum: 1, maximum: 31 },
|
||||
},
|
||||
required: ['month', 'day'],
|
||||
},
|
||||
end: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
month: { type: 'integer', minimum: 1, maximum: 12 },
|
||||
day: { type: 'integer', minimum: 1, maximum: 31 },
|
||||
},
|
||||
required: ['month', 'day'],
|
||||
},
|
||||
},
|
||||
required: ['begin', 'end'],
|
||||
}],
|
||||
},
|
||||
},
|
||||
required: ['birthday'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.followingsRepository
|
||||
.createQueryBuilder('following')
|
||||
.andWhere('following.followerId = :userId', { userId: me.id })
|
||||
.innerJoin(this.userProfilesRepository.metadata.targetName, 'followeeProfile', 'followeeProfile.userId = following.followeeId');
|
||||
|
||||
if (Object.hasOwn(ps.birthday, 'begin') && Object.hasOwn(ps.birthday, 'end')) {
|
||||
const range = ps.birthday as { begin: { month: number; day: number }; end: { month: number; day: number }; };
|
||||
|
||||
// 誕生日は mmdd の形式の最大4桁の数字(例: 8月30日 → 830)でインデックスが効くようになっているので、その形式に変換
|
||||
const begin = range.begin.month * 100 + range.begin.day;
|
||||
const end = range.end.month * 100 + range.end.day;
|
||||
|
||||
if (begin <= end) {
|
||||
query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :begin AND :end', { begin, end });
|
||||
} else {
|
||||
// 12/31 から 1/1 の範囲を取得するために OR で対応
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.where('get_birthday_date(followeeProfile.birthday) BETWEEN :begin AND 1231', { begin });
|
||||
qb.orWhere('get_birthday_date(followeeProfile.birthday) BETWEEN 101 AND :end', { end });
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
const { month, day } = ps.birthday as { month: number; day: number };
|
||||
// なぜか get_birthday_date() = :birthday だとインデックスが効かないので、BETWEEN で対応
|
||||
query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :birthday AND :birthday', { birthday: month * 100 + day });
|
||||
}
|
||||
|
||||
query.select('following.followeeId', 'user_id');
|
||||
query.addSelect('get_birthday_date(followeeProfile.birthday)', 'birthday_date');
|
||||
query.orderBy('birthday_date', 'ASC');
|
||||
|
||||
const birthdayUsers = await query
|
||||
.offset(ps.offset).limit(ps.limit)
|
||||
.getRawMany<{ birthday_date: number; user_id: string }>();
|
||||
|
||||
const users = new Map<string, Packed<'UserLite'>>((
|
||||
await this.userEntityService.packMany(
|
||||
birthdayUsers.map(u => u.user_id),
|
||||
me,
|
||||
{ schema: 'UserLite' },
|
||||
)
|
||||
).map(u => [u.id, u]));
|
||||
|
||||
return birthdayUsers
|
||||
.map(item => {
|
||||
const birthday = new Date();
|
||||
birthday.setHours(0, 0, 0, 0);
|
||||
// item.birthday_date は mmdd の形式の最大4桁の数字(例: 8月30日 → 830)で出力されるので、日付に戻してDateオブジェクトに設定
|
||||
birthday.setMonth(Math.floor(item.birthday_date / 100) - 1, item.birthday_date % 100);
|
||||
|
||||
if (birthday.getTime() < new Date().setHours(0, 0, 0, 0)) {
|
||||
birthday.setFullYear(new Date().getFullYear() + 1);
|
||||
}
|
||||
|
||||
const birthdayStr = `${birthday.getFullYear()}-${(birthday.getMonth() + 1).toString().padStart(2, '0')}-${(birthday.getDate()).toString().padStart(2, '0')}`;
|
||||
return {
|
||||
id: item.user_id,
|
||||
birthday: birthdayStr,
|
||||
user: users.get(item.user_id),
|
||||
};
|
||||
})
|
||||
.filter(item => item.user != null)
|
||||
.map(item => item as { id: string; birthday: string; user: Packed<'UserLite'> });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,72 +4,54 @@
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { HybridTimelineChannel } from './channels/hybrid-timeline.js';
|
||||
import { LocalTimelineChannel } from './channels/local-timeline.js';
|
||||
import { HomeTimelineChannel } from './channels/home-timeline.js';
|
||||
import { GlobalTimelineChannel } from './channels/global-timeline.js';
|
||||
import { MainChannel } from './channels/main.js';
|
||||
import { ChannelChannel } from './channels/channel.js';
|
||||
import { AdminChannel } from './channels/admin.js';
|
||||
import { ServerStatsChannel } from './channels/server-stats.js';
|
||||
import { QueueStatsChannel } from './channels/queue-stats.js';
|
||||
import { UserListChannel } from './channels/user-list.js';
|
||||
import { AntennaChannel } from './channels/antenna.js';
|
||||
import { DriveChannel } from './channels/drive.js';
|
||||
import { HashtagChannel } from './channels/hashtag.js';
|
||||
import { RoleTimelineChannel } from './channels/role-timeline.js';
|
||||
import { ChatUserChannel } from './channels/chat-user.js';
|
||||
import { ChatRoomChannel } from './channels/chat-room.js';
|
||||
import { ReversiChannel } from './channels/reversi.js';
|
||||
import { ReversiGameChannel } from './channels/reversi-game.js';
|
||||
import type { ChannelConstructor } from './channel.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { HybridTimelineChannelService } from './channels/hybrid-timeline.js';
|
||||
import { LocalTimelineChannelService } from './channels/local-timeline.js';
|
||||
import { HomeTimelineChannelService } from './channels/home-timeline.js';
|
||||
import { GlobalTimelineChannelService } from './channels/global-timeline.js';
|
||||
import { MainChannelService } from './channels/main.js';
|
||||
import { ChannelChannelService } from './channels/channel.js';
|
||||
import { AdminChannelService } from './channels/admin.js';
|
||||
import { ServerStatsChannelService } from './channels/server-stats.js';
|
||||
import { QueueStatsChannelService } from './channels/queue-stats.js';
|
||||
import { UserListChannelService } from './channels/user-list.js';
|
||||
import { AntennaChannelService } from './channels/antenna.js';
|
||||
import { DriveChannelService } from './channels/drive.js';
|
||||
import { HashtagChannelService } from './channels/hashtag.js';
|
||||
import { RoleTimelineChannelService } from './channels/role-timeline.js';
|
||||
import { ChatUserChannelService } from './channels/chat-user.js';
|
||||
import { ChatRoomChannelService } from './channels/chat-room.js';
|
||||
import { ReversiChannelService } from './channels/reversi.js';
|
||||
import { ReversiGameChannelService } from './channels/reversi-game.js';
|
||||
import { type MiChannelService } from './channel.js';
|
||||
|
||||
@Injectable()
|
||||
export class ChannelsService {
|
||||
constructor(
|
||||
private mainChannelService: MainChannelService,
|
||||
private homeTimelineChannelService: HomeTimelineChannelService,
|
||||
private localTimelineChannelService: LocalTimelineChannelService,
|
||||
private hybridTimelineChannelService: HybridTimelineChannelService,
|
||||
private globalTimelineChannelService: GlobalTimelineChannelService,
|
||||
private userListChannelService: UserListChannelService,
|
||||
private hashtagChannelService: HashtagChannelService,
|
||||
private roleTimelineChannelService: RoleTimelineChannelService,
|
||||
private antennaChannelService: AntennaChannelService,
|
||||
private channelChannelService: ChannelChannelService,
|
||||
private driveChannelService: DriveChannelService,
|
||||
private serverStatsChannelService: ServerStatsChannelService,
|
||||
private queueStatsChannelService: QueueStatsChannelService,
|
||||
private adminChannelService: AdminChannelService,
|
||||
private chatUserChannelService: ChatUserChannelService,
|
||||
private chatRoomChannelService: ChatRoomChannelService,
|
||||
private reversiChannelService: ReversiChannelService,
|
||||
private reversiGameChannelService: ReversiGameChannelService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getChannelService(name: string): MiChannelService<boolean> {
|
||||
public getChannelConstructor(name: string): ChannelConstructor<boolean> {
|
||||
switch (name) {
|
||||
case 'main': return this.mainChannelService;
|
||||
case 'homeTimeline': return this.homeTimelineChannelService;
|
||||
case 'localTimeline': return this.localTimelineChannelService;
|
||||
case 'hybridTimeline': return this.hybridTimelineChannelService;
|
||||
case 'globalTimeline': return this.globalTimelineChannelService;
|
||||
case 'userList': return this.userListChannelService;
|
||||
case 'hashtag': return this.hashtagChannelService;
|
||||
case 'roleTimeline': return this.roleTimelineChannelService;
|
||||
case 'antenna': return this.antennaChannelService;
|
||||
case 'channel': return this.channelChannelService;
|
||||
case 'drive': return this.driveChannelService;
|
||||
case 'serverStats': return this.serverStatsChannelService;
|
||||
case 'queueStats': return this.queueStatsChannelService;
|
||||
case 'admin': return this.adminChannelService;
|
||||
case 'chatUser': return this.chatUserChannelService;
|
||||
case 'chatRoom': return this.chatRoomChannelService;
|
||||
case 'reversi': return this.reversiChannelService;
|
||||
case 'reversiGame': return this.reversiGameChannelService;
|
||||
case 'main': return MainChannel;
|
||||
case 'homeTimeline': return HomeTimelineChannel;
|
||||
case 'localTimeline': return LocalTimelineChannel;
|
||||
case 'hybridTimeline': return HybridTimelineChannel;
|
||||
case 'globalTimeline': return GlobalTimelineChannel;
|
||||
case 'userList': return UserListChannel;
|
||||
case 'hashtag': return HashtagChannel;
|
||||
case 'roleTimeline': return RoleTimelineChannel;
|
||||
case 'antenna': return AntennaChannel;
|
||||
case 'channel': return ChannelChannel;
|
||||
case 'drive': return DriveChannel;
|
||||
case 'serverStats': return ServerStatsChannel;
|
||||
case 'queueStats': return QueueStatsChannel;
|
||||
case 'admin': return AdminChannel;
|
||||
case 'chatUser': return ChatUserChannel;
|
||||
case 'chatRoom': return ChatRoomChannel;
|
||||
case 'reversi': return ReversiChannel;
|
||||
case 'reversiGame': return ReversiGameChannel;
|
||||
|
||||
default:
|
||||
throw new Error(`no such channel: ${name}`);
|
||||
|
||||
@@ -6,19 +6,39 @@
|
||||
import * as WebSocket from 'ws';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { MiAccessToken } from '@/models/AccessToken.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { NotificationService } from '@/core/NotificationService.js';
|
||||
import { NotificationService } from '@/core/NotificationService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { MiFollowing, MiUserProfile } from '@/models/_.js';
|
||||
import type { GlobalEvents, StreamEventEmitter } from '@/core/GlobalEventService.js';
|
||||
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { isJsonObject } from '@/misc/json-value.js';
|
||||
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
|
||||
import type { ChannelsService } from './ChannelsService.js';
|
||||
import { isJsonObject } from '@/misc/json-value.js';
|
||||
import type { EventEmitter } from 'events';
|
||||
import type Channel from './channel.js';
|
||||
import type { ChannelConstructor } from './channel.js';
|
||||
import type { ChannelRequest } from './channel.js';
|
||||
import { ContextIdFactory, ModuleRef, REQUEST } from '@nestjs/core';
|
||||
import { Inject, Injectable, Scope } from '@nestjs/common';
|
||||
import { MainChannel } from '@/server/api/stream/channels/main.js';
|
||||
import { HomeTimelineChannel } from '@/server/api/stream/channels/home-timeline.js';
|
||||
import { LocalTimelineChannel } from '@/server/api/stream/channels/local-timeline.js';
|
||||
import { HybridTimelineChannel } from '@/server/api/stream/channels/hybrid-timeline.js';
|
||||
import { GlobalTimelineChannel } from '@/server/api/stream/channels/global-timeline.js';
|
||||
import { UserListChannel } from '@/server/api/stream/channels/user-list.js';
|
||||
import { HashtagChannel } from '@/server/api/stream/channels/hashtag.js';
|
||||
import { RoleTimelineChannel } from '@/server/api/stream/channels/role-timeline.js';
|
||||
import { AntennaChannel } from '@/server/api/stream/channels/antenna.js';
|
||||
import { ChannelChannel } from '@/server/api/stream/channels/channel.js';
|
||||
import { DriveChannel } from '@/server/api/stream/channels/drive.js';
|
||||
import { ServerStatsChannel } from '@/server/api/stream/channels/server-stats.js';
|
||||
import { QueueStatsChannel } from '@/server/api/stream/channels/queue-stats.js';
|
||||
import { AdminChannel } from '@/server/api/stream/channels/admin.js';
|
||||
import { ChatUserChannel } from '@/server/api/stream/channels/chat-user.js';
|
||||
import { ChatRoomChannel } from '@/server/api/stream/channels/chat-room.js';
|
||||
import { ReversiChannel } from '@/server/api/stream/channels/reversi.js';
|
||||
import { ReversiGameChannel } from '@/server/api/stream/channels/reversi-game.js';
|
||||
|
||||
const MAX_CHANNELS_PER_CONNECTION = 32;
|
||||
|
||||
@@ -26,6 +46,7 @@ const MAX_CHANNELS_PER_CONNECTION = 32;
|
||||
* Main stream connection
|
||||
*/
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable({ scope: Scope.TRANSIENT })
|
||||
export default class Connection {
|
||||
public user?: MiUser;
|
||||
public token?: MiAccessToken;
|
||||
@@ -44,16 +65,16 @@ export default class Connection {
|
||||
private fetchIntervalId: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
private channelsService: ChannelsService,
|
||||
private moduleRef: ModuleRef,
|
||||
private notificationService: NotificationService,
|
||||
private cacheService: CacheService,
|
||||
private channelFollowingService: ChannelFollowingService,
|
||||
private channelMutingService: ChannelMutingService,
|
||||
user: MiUser | null | undefined,
|
||||
token: MiAccessToken | null | undefined,
|
||||
@Inject(REQUEST)
|
||||
request: ConnectionRequest,
|
||||
) {
|
||||
if (user) this.user = user;
|
||||
if (token) this.token = token;
|
||||
if (request.user) this.user = request.user;
|
||||
if (request.token) this.token = request.token;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -232,28 +253,34 @@ export default class Connection {
|
||||
* チャンネルに接続
|
||||
*/
|
||||
@bindThis
|
||||
public connectChannel(id: string, params: JsonObject | undefined, channel: string, pong = false) {
|
||||
public async connectChannel(id: string, params: JsonObject | undefined, channel: string, pong = false) {
|
||||
if (this.channels.length >= MAX_CHANNELS_PER_CONNECTION) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channelService = this.channelsService.getChannelService(channel);
|
||||
const channelConstructor = this.getChannelConstructor(channel);
|
||||
|
||||
if (channelService.requireCredential && this.user == null) {
|
||||
if (channelConstructor.requireCredential && this.user == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.token && ((channelService.kind && !this.token.permission.some(p => p === channelService.kind))
|
||||
|| (!channelService.kind && channelService.requireCredential))) {
|
||||
if (this.token && ((channelConstructor.kind && !this.token.permission.some(p => p === channelConstructor.kind))
|
||||
|| (!channelConstructor.kind && channelConstructor.requireCredential))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 共有可能チャンネルに接続しようとしていて、かつそのチャンネルに既に接続していたら無意味なので無視
|
||||
if (channelService.shouldShare && this.channels.some(c => c.chName === channel)) {
|
||||
if (channelConstructor.shouldShare && this.channels.some(c => c.chName === channel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ch: Channel = channelService.create(id, this);
|
||||
const contextId = ContextIdFactory.create();
|
||||
this.moduleRef.registerRequestByContextId<ChannelRequest>({
|
||||
id: id,
|
||||
connection: this,
|
||||
}, contextId);
|
||||
const ch: Channel = await this.moduleRef.create<Channel>(channelConstructor, contextId);
|
||||
|
||||
this.channels.push(ch);
|
||||
ch.init(params ?? {});
|
||||
|
||||
@@ -264,6 +291,33 @@ export default class Connection {
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getChannelConstructor(name: string): ChannelConstructor<boolean> {
|
||||
switch (name) {
|
||||
case 'main': return MainChannel;
|
||||
case 'homeTimeline': return HomeTimelineChannel;
|
||||
case 'localTimeline': return LocalTimelineChannel;
|
||||
case 'hybridTimeline': return HybridTimelineChannel;
|
||||
case 'globalTimeline': return GlobalTimelineChannel;
|
||||
case 'userList': return UserListChannel;
|
||||
case 'hashtag': return HashtagChannel;
|
||||
case 'roleTimeline': return RoleTimelineChannel;
|
||||
case 'antenna': return AntennaChannel;
|
||||
case 'channel': return ChannelChannel;
|
||||
case 'drive': return DriveChannel;
|
||||
case 'serverStats': return ServerStatsChannel;
|
||||
case 'queueStats': return QueueStatsChannel;
|
||||
case 'admin': return AdminChannel;
|
||||
case 'chatUser': return ChatUserChannel;
|
||||
case 'chatRoom': return ChatRoomChannel;
|
||||
case 'reversi': return ReversiChannel;
|
||||
case 'reversiGame': return ReversiGameChannel;
|
||||
|
||||
default:
|
||||
throw new Error(`no such channel: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* チャンネルから切断
|
||||
* @param id チャンネルコネクションID
|
||||
@@ -306,3 +360,8 @@ export default class Connection {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface ConnectionRequest {
|
||||
user: MiUser | null | undefined,
|
||||
token: MiAccessToken | null | undefined,
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export default abstract class Channel {
|
||||
public abstract readonly chName: string;
|
||||
public static readonly shouldShare: boolean;
|
||||
public static readonly requireCredential: boolean;
|
||||
public static readonly kind?: string | null;
|
||||
public static readonly kind: string | null;
|
||||
|
||||
protected get user() {
|
||||
return this.connection.user;
|
||||
@@ -85,9 +85,9 @@ export default abstract class Channel {
|
||||
return false;
|
||||
}
|
||||
|
||||
constructor(id: string, connection: Connection) {
|
||||
this.id = id;
|
||||
this.connection = connection;
|
||||
constructor(request: ChannelRequest) {
|
||||
this.id = request.id;
|
||||
this.connection = request.connection;
|
||||
}
|
||||
|
||||
public send(payload: { type: string, body: JsonValue }): void;
|
||||
@@ -111,9 +111,14 @@ export default abstract class Channel {
|
||||
public onMessage?(type: string, body: JsonValue): void;
|
||||
}
|
||||
|
||||
export type MiChannelService<T extends boolean> = {
|
||||
export interface ChannelRequest {
|
||||
id: string,
|
||||
connection: Connection,
|
||||
}
|
||||
|
||||
export interface ChannelConstructor<T extends boolean> {
|
||||
new(...args: any[]): Channel;
|
||||
shouldShare: boolean;
|
||||
requireCredential: T;
|
||||
kind: T extends true ? string : string | null | undefined;
|
||||
create: (id: string, connection: Connection) => Channel;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,17 +3,26 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, Scope } from '@nestjs/common';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { JsonObject } from '@/misc/json-value.js';
|
||||
import Channel, { type MiChannelService } from '../channel.js';
|
||||
import Channel, { type ChannelRequest } from '../channel.js';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
|
||||
class AdminChannel extends Channel {
|
||||
@Injectable({ scope: Scope.TRANSIENT })
|
||||
export class AdminChannel extends Channel {
|
||||
public readonly chName = 'admin';
|
||||
public static shouldShare = true;
|
||||
public static requireCredential = true as const;
|
||||
public static kind = 'read:admin:stream';
|
||||
|
||||
constructor(
|
||||
@Inject(REQUEST)
|
||||
request: ChannelRequest,
|
||||
) {
|
||||
super(request);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async init(params: JsonObject) {
|
||||
// Subscribe admin stream
|
||||
@@ -22,22 +31,3 @@ class AdminChannel extends Channel {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminChannelService implements MiChannelService<true> {
|
||||
public readonly shouldShare = AdminChannel.shouldShare;
|
||||
public readonly requireCredential = AdminChannel.requireCredential;
|
||||
public readonly kind = AdminChannel.kind;
|
||||
|
||||
constructor(
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public create(id: string, connection: Channel['connection']): AdminChannel {
|
||||
return new AdminChannel(
|
||||
id,
|
||||
connection,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,16 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, Scope } from '@nestjs/common';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
import type { JsonObject } from '@/misc/json-value.js';
|
||||
import Channel, { type MiChannelService } from '../channel.js';
|
||||
import Channel, { type ChannelRequest } from '../channel.js';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
|
||||
class AntennaChannel extends Channel {
|
||||
@Injectable({ scope: Scope.TRANSIENT })
|
||||
export class AntennaChannel extends Channel {
|
||||
public readonly chName = 'antenna';
|
||||
public static shouldShare = false;
|
||||
public static requireCredential = true as const;
|
||||
@@ -18,12 +20,12 @@ class AntennaChannel extends Channel {
|
||||
private antennaId: string;
|
||||
|
||||
constructor(
|
||||
private noteEntityService: NoteEntityService,
|
||||
@Inject(REQUEST)
|
||||
request: ChannelRequest,
|
||||
|
||||
id: string,
|
||||
connection: Channel['connection'],
|
||||
private noteEntityService: NoteEntityService,
|
||||
) {
|
||||
super(id, connection);
|
||||
super(request);
|
||||
//this.onEvent = this.onEvent.bind(this);
|
||||
}
|
||||
|
||||
@@ -55,24 +57,3 @@ class AntennaChannel extends Channel {
|
||||
this.subscriber.off(`antennaStream:${this.antennaId}`, this.onEvent);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AntennaChannelService implements MiChannelService<true> {
|
||||
public readonly shouldShare = AntennaChannel.shouldShare;
|
||||
public readonly requireCredential = AntennaChannel.requireCredential;
|
||||
public readonly kind = AntennaChannel.kind;
|
||||
|
||||
constructor(
|
||||
private noteEntityService: NoteEntityService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public create(id: string, connection: Channel['connection']): AntennaChannel {
|
||||
return new AntennaChannel(
|
||||
this.noteEntityService,
|
||||
id,
|
||||
connection,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, Scope } from '@nestjs/common';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
@@ -11,20 +11,23 @@ import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
|
||||
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
|
||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||
import type { JsonObject } from '@/misc/json-value.js';
|
||||
import Channel, { type MiChannelService } from '../channel.js';
|
||||
import Channel, { type ChannelRequest } from '../channel.js';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
|
||||
class ChannelChannel extends Channel {
|
||||
@Injectable({ scope: Scope.TRANSIENT })
|
||||
export class ChannelChannel extends Channel {
|
||||
public readonly chName = 'channel';
|
||||
public static shouldShare = false;
|
||||
public static requireCredential = false as const;
|
||||
private channelId: string;
|
||||
|
||||
constructor(
|
||||
@Inject(REQUEST)
|
||||
request: ChannelRequest,
|
||||
|
||||
private noteEntityService: NoteEntityService,
|
||||
id: string,
|
||||
connection: Channel['connection'],
|
||||
) {
|
||||
super(id, connection);
|
||||
super(request);
|
||||
//this.onNote = this.onNote.bind(this);
|
||||
}
|
||||
|
||||
@@ -92,24 +95,3 @@ class ChannelChannel extends Channel {
|
||||
this.subscriber.off('notesStream', this.onNote);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ChannelChannelService implements MiChannelService<false> {
|
||||
public readonly shouldShare = ChannelChannel.shouldShare;
|
||||
public readonly requireCredential = ChannelChannel.requireCredential;
|
||||
public readonly kind = ChannelChannel.kind;
|
||||
|
||||
constructor(
|
||||
private noteEntityService: NoteEntityService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public create(id: string, connection: Channel['connection']): ChannelChannel {
|
||||
return new ChannelChannel(
|
||||
this.noteEntityService,
|
||||
id,
|
||||
connection,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,16 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, Scope } from '@nestjs/common';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
import type { JsonObject } from '@/misc/json-value.js';
|
||||
import { ChatService } from '@/core/ChatService.js';
|
||||
import Channel, { type MiChannelService } from '../channel.js';
|
||||
import Channel, { type ChannelRequest } from '../channel.js';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
|
||||
class ChatRoomChannel extends Channel {
|
||||
@Injectable({ scope: Scope.TRANSIENT })
|
||||
export class ChatRoomChannel extends Channel {
|
||||
public readonly chName = 'chatRoom';
|
||||
public static shouldShare = false;
|
||||
public static requireCredential = true as const;
|
||||
@@ -18,12 +20,12 @@ class ChatRoomChannel extends Channel {
|
||||
private roomId: string;
|
||||
|
||||
constructor(
|
||||
private chatService: ChatService,
|
||||
@Inject(REQUEST)
|
||||
request: ChannelRequest,
|
||||
|
||||
id: string,
|
||||
connection: Channel['connection'],
|
||||
private chatService: ChatService,
|
||||
) {
|
||||
super(id, connection);
|
||||
super(request);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -55,24 +57,3 @@ class ChatRoomChannel extends Channel {
|
||||
this.subscriber.off(`chatRoomStream:${this.roomId}`, this.onEvent);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ChatRoomChannelService implements MiChannelService<true> {
|
||||
public readonly shouldShare = ChatRoomChannel.shouldShare;
|
||||
public readonly requireCredential = ChatRoomChannel.requireCredential;
|
||||
public readonly kind = ChatRoomChannel.kind;
|
||||
|
||||
constructor(
|
||||
private chatService: ChatService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public create(id: string, connection: Channel['connection']): ChatRoomChannel {
|
||||
return new ChatRoomChannel(
|
||||
this.chatService,
|
||||
id,
|
||||
connection,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,16 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, Scope } from '@nestjs/common';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
import type { JsonObject } from '@/misc/json-value.js';
|
||||
import { ChatService } from '@/core/ChatService.js';
|
||||
import Channel, { type MiChannelService } from '../channel.js';
|
||||
import Channel, { type ChannelRequest } from '../channel.js';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
|
||||
class ChatUserChannel extends Channel {
|
||||
@Injectable({ scope: Scope.TRANSIENT })
|
||||
export class ChatUserChannel extends Channel {
|
||||
public readonly chName = 'chatUser';
|
||||
public static shouldShare = false;
|
||||
public static requireCredential = true as const;
|
||||
@@ -18,12 +20,12 @@ class ChatUserChannel extends Channel {
|
||||
private otherId: string;
|
||||
|
||||
constructor(
|
||||
private chatService: ChatService,
|
||||
@Inject(REQUEST)
|
||||
request: ChannelRequest,
|
||||
|
||||
id: string,
|
||||
connection: Channel['connection'],
|
||||
private chatService: ChatService,
|
||||
) {
|
||||
super(id, connection);
|
||||
super(request);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -55,24 +57,3 @@ class ChatUserChannel extends Channel {
|
||||
this.subscriber.off(`chatUserStream:${this.user!.id}-${this.otherId}`, this.onEvent);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ChatUserChannelService implements MiChannelService<true> {
|
||||
public readonly shouldShare = ChatUserChannel.shouldShare;
|
||||
public readonly requireCredential = ChatUserChannel.requireCredential;
|
||||
public readonly kind = ChatUserChannel.kind;
|
||||
|
||||
constructor(
|
||||
private chatService: ChatService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public create(id: string, connection: Channel['connection']): ChatUserChannel {
|
||||
return new ChatUserChannel(
|
||||
this.chatService,
|
||||
id,
|
||||
connection,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,17 +3,26 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, Scope } from '@nestjs/common';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { JsonObject } from '@/misc/json-value.js';
|
||||
import Channel, { type MiChannelService } from '../channel.js';
|
||||
import Channel, { type ChannelRequest } from '../channel.js';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
|
||||
class DriveChannel extends Channel {
|
||||
@Injectable({ scope: Scope.TRANSIENT })
|
||||
export class DriveChannel extends Channel {
|
||||
public readonly chName = 'drive';
|
||||
public static shouldShare = true;
|
||||
public static requireCredential = true as const;
|
||||
public static kind = 'read:account';
|
||||
|
||||
constructor(
|
||||
@Inject(REQUEST)
|
||||
request: ChannelRequest,
|
||||
) {
|
||||
super(request);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async init(params: JsonObject) {
|
||||
// Subscribe drive stream
|
||||
@@ -22,22 +31,3 @@ class DriveChannel extends Channel {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DriveChannelService implements MiChannelService<true> {
|
||||
public readonly shouldShare = DriveChannel.shouldShare;
|
||||
public readonly requireCredential = DriveChannel.requireCredential;
|
||||
public readonly kind = DriveChannel.kind;
|
||||
|
||||
constructor(
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public create(id: string, connection: Channel['connection']): DriveChannel {
|
||||
return new DriveChannel(
|
||||
id,
|
||||
connection,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, Scope } from '@nestjs/common';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
@@ -11,9 +11,11 @@ import { bindThis } from '@/decorators.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
|
||||
import type { JsonObject } from '@/misc/json-value.js';
|
||||
import Channel, { type MiChannelService } from '../channel.js';
|
||||
import Channel, { type ChannelRequest } from '../channel.js';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
|
||||
class GlobalTimelineChannel extends Channel {
|
||||
@Injectable({ scope: Scope.TRANSIENT })
|
||||
export class GlobalTimelineChannel extends Channel {
|
||||
public readonly chName = 'globalTimeline';
|
||||
public static shouldShare = false;
|
||||
public static requireCredential = false as const;
|
||||
@@ -21,14 +23,14 @@ class GlobalTimelineChannel extends Channel {
|
||||
private withFiles: boolean;
|
||||
|
||||
constructor(
|
||||
@Inject(REQUEST)
|
||||
request: ChannelRequest,
|
||||
|
||||
private metaService: MetaService,
|
||||
private roleService: RoleService,
|
||||
private noteEntityService: NoteEntityService,
|
||||
|
||||
id: string,
|
||||
connection: Channel['connection'],
|
||||
) {
|
||||
super(id, connection);
|
||||
super(request);
|
||||
//this.onNote = this.onNote.bind(this);
|
||||
}
|
||||
|
||||
@@ -74,28 +76,3 @@ class GlobalTimelineChannel extends Channel {
|
||||
this.subscriber.off('notesStream', this.onNote);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GlobalTimelineChannelService implements MiChannelService<false> {
|
||||
public readonly shouldShare = GlobalTimelineChannel.shouldShare;
|
||||
public readonly requireCredential = GlobalTimelineChannel.requireCredential;
|
||||
public readonly kind = GlobalTimelineChannel.kind;
|
||||
|
||||
constructor(
|
||||
private metaService: MetaService,
|
||||
private roleService: RoleService,
|
||||
private noteEntityService: NoteEntityService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public create(id: string, connection: Channel['connection']): GlobalTimelineChannel {
|
||||
return new GlobalTimelineChannel(
|
||||
this.metaService,
|
||||
this.roleService,
|
||||
this.noteEntityService,
|
||||
id,
|
||||
connection,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,28 +3,30 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, Scope } from '@nestjs/common';
|
||||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
|
||||
import type { JsonObject } from '@/misc/json-value.js';
|
||||
import Channel, { type MiChannelService } from '../channel.js';
|
||||
import Channel, { type ChannelRequest } from '../channel.js';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
|
||||
class HashtagChannel extends Channel {
|
||||
@Injectable({ scope: Scope.TRANSIENT })
|
||||
export class HashtagChannel extends Channel {
|
||||
public readonly chName = 'hashtag';
|
||||
public static shouldShare = false;
|
||||
public static requireCredential = false as const;
|
||||
private q: string[][];
|
||||
|
||||
constructor(
|
||||
private noteEntityService: NoteEntityService,
|
||||
@Inject(REQUEST)
|
||||
request: ChannelRequest,
|
||||
|
||||
id: string,
|
||||
connection: Channel['connection'],
|
||||
private noteEntityService: NoteEntityService,
|
||||
) {
|
||||
super(id, connection);
|
||||
super(request);
|
||||
//this.onNote = this.onNote.bind(this);
|
||||
}
|
||||
|
||||
@@ -62,24 +64,3 @@ class HashtagChannel extends Channel {
|
||||
this.subscriber.off('notesStream', this.onNote);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HashtagChannelService implements MiChannelService<false> {
|
||||
public readonly shouldShare = HashtagChannel.shouldShare;
|
||||
public readonly requireCredential = HashtagChannel.requireCredential;
|
||||
public readonly kind = HashtagChannel.kind;
|
||||
|
||||
constructor(
|
||||
private noteEntityService: NoteEntityService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public create(id: string, connection: Channel['connection']): HashtagChannel {
|
||||
return new HashtagChannel(
|
||||
this.noteEntityService,
|
||||
id,
|
||||
connection,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,17 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, Scope } from '@nestjs/common';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
|
||||
import type { JsonObject } from '@/misc/json-value.js';
|
||||
import Channel, { type MiChannelService } from '../channel.js';
|
||||
import Channel, { type ChannelRequest } from '../channel.js';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
|
||||
class HomeTimelineChannel extends Channel {
|
||||
@Injectable({ scope: Scope.TRANSIENT })
|
||||
export class HomeTimelineChannel extends Channel {
|
||||
public readonly chName = 'homeTimeline';
|
||||
public static shouldShare = false;
|
||||
public static requireCredential = true as const;
|
||||
@@ -20,12 +22,12 @@ class HomeTimelineChannel extends Channel {
|
||||
private withFiles: boolean;
|
||||
|
||||
constructor(
|
||||
private noteEntityService: NoteEntityService,
|
||||
@Inject(REQUEST)
|
||||
request: ChannelRequest,
|
||||
|
||||
id: string,
|
||||
connection: Channel['connection'],
|
||||
private noteEntityService: NoteEntityService,
|
||||
) {
|
||||
super(id, connection);
|
||||
super(request);
|
||||
//this.onNote = this.onNote.bind(this);
|
||||
}
|
||||
|
||||
@@ -98,24 +100,3 @@ class HomeTimelineChannel extends Channel {
|
||||
this.subscriber.off('notesStream', this.onNote);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HomeTimelineChannelService implements MiChannelService<true> {
|
||||
public readonly shouldShare = HomeTimelineChannel.shouldShare;
|
||||
public readonly requireCredential = HomeTimelineChannel.requireCredential;
|
||||
public readonly kind = HomeTimelineChannel.kind;
|
||||
|
||||
constructor(
|
||||
private noteEntityService: NoteEntityService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public create(id: string, connection: Channel['connection']): HomeTimelineChannel {
|
||||
return new HomeTimelineChannel(
|
||||
this.noteEntityService,
|
||||
id,
|
||||
connection,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, Scope } from '@nestjs/common';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
@@ -11,9 +11,11 @@ import { bindThis } from '@/decorators.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
|
||||
import type { JsonObject } from '@/misc/json-value.js';
|
||||
import Channel, { type MiChannelService } from '../channel.js';
|
||||
import Channel, { type ChannelRequest } from '../channel.js';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
|
||||
class HybridTimelineChannel extends Channel {
|
||||
@Injectable({ scope: Scope.TRANSIENT })
|
||||
export class HybridTimelineChannel extends Channel {
|
||||
public readonly chName = 'hybridTimeline';
|
||||
public static shouldShare = false;
|
||||
public static requireCredential = true as const;
|
||||
@@ -23,14 +25,14 @@ class HybridTimelineChannel extends Channel {
|
||||
private withFiles: boolean;
|
||||
|
||||
constructor(
|
||||
@Inject(REQUEST)
|
||||
request: ChannelRequest,
|
||||
|
||||
private metaService: MetaService,
|
||||
private roleService: RoleService,
|
||||
private noteEntityService: NoteEntityService,
|
||||
|
||||
id: string,
|
||||
connection: Channel['connection'],
|
||||
) {
|
||||
super(id, connection);
|
||||
super(request);
|
||||
//this.onNote = this.onNote.bind(this);
|
||||
}
|
||||
|
||||
@@ -118,28 +120,3 @@ class HybridTimelineChannel extends Channel {
|
||||
this.subscriber.off('notesStream', this.onNote);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HybridTimelineChannelService implements MiChannelService<true> {
|
||||
public readonly shouldShare = HybridTimelineChannel.shouldShare;
|
||||
public readonly requireCredential = HybridTimelineChannel.requireCredential;
|
||||
public readonly kind = HybridTimelineChannel.kind;
|
||||
|
||||
constructor(
|
||||
private metaService: MetaService,
|
||||
private roleService: RoleService,
|
||||
private noteEntityService: NoteEntityService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public create(id: string, connection: Channel['connection']): HybridTimelineChannel {
|
||||
return new HybridTimelineChannel(
|
||||
this.metaService,
|
||||
this.roleService,
|
||||
this.noteEntityService,
|
||||
id,
|
||||
connection,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, Scope } from '@nestjs/common';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
@@ -11,25 +11,27 @@ import { bindThis } from '@/decorators.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js';
|
||||
import type { JsonObject } from '@/misc/json-value.js';
|
||||
import Channel, { type MiChannelService } from '../channel.js';
|
||||
import Channel, { type ChannelRequest } from '../channel.js';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
|
||||
class LocalTimelineChannel extends Channel {
|
||||
@Injectable({ scope: Scope.TRANSIENT })
|
||||
export class LocalTimelineChannel extends Channel {
|
||||
public readonly chName = 'localTimeline';
|
||||
public static shouldShare = false;
|
||||
public static shouldShare = false as const;
|
||||
public static requireCredential = false as const;
|
||||
private withRenotes: boolean;
|
||||
private withReplies: boolean;
|
||||
private withFiles: boolean;
|
||||
|
||||
constructor(
|
||||
@Inject(REQUEST)
|
||||
request: ChannelRequest,
|
||||
|
||||
private metaService: MetaService,
|
||||
private roleService: RoleService,
|
||||
private noteEntityService: NoteEntityService,
|
||||
|
||||
id: string,
|
||||
connection: Channel['connection'],
|
||||
) {
|
||||
super(id, connection);
|
||||
super(request);
|
||||
//this.onNote = this.onNote.bind(this);
|
||||
}
|
||||
|
||||
@@ -84,28 +86,3 @@ class LocalTimelineChannel extends Channel {
|
||||
this.subscriber.off('notesStream', this.onNote);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LocalTimelineChannelService implements MiChannelService<false> {
|
||||
public readonly shouldShare = LocalTimelineChannel.shouldShare;
|
||||
public readonly requireCredential = LocalTimelineChannel.requireCredential;
|
||||
public readonly kind = LocalTimelineChannel.kind;
|
||||
|
||||
constructor(
|
||||
private metaService: MetaService,
|
||||
private roleService: RoleService,
|
||||
private noteEntityService: NoteEntityService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public create(id: string, connection: Channel['connection']): LocalTimelineChannel {
|
||||
return new LocalTimelineChannel(
|
||||
this.metaService,
|
||||
this.roleService,
|
||||
this.noteEntityService,
|
||||
id,
|
||||
connection,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,26 +3,28 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, Scope } from '@nestjs/common';
|
||||
import { isInstanceMuted, isUserFromMutedInstance } from '@/misc/is-instance-muted.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { JsonObject } from '@/misc/json-value.js';
|
||||
import Channel, { type MiChannelService } from '../channel.js';
|
||||
import Channel, { type ChannelRequest } from '../channel.js';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
|
||||
class MainChannel extends Channel {
|
||||
@Injectable({ scope: Scope.TRANSIENT })
|
||||
export class MainChannel extends Channel {
|
||||
public readonly chName = 'main';
|
||||
public static shouldShare = true;
|
||||
public static requireCredential = true as const;
|
||||
public static kind = 'read:account';
|
||||
|
||||
constructor(
|
||||
private noteEntityService: NoteEntityService,
|
||||
@Inject(REQUEST)
|
||||
request: ChannelRequest,
|
||||
|
||||
id: string,
|
||||
connection: Channel['connection'],
|
||||
private noteEntityService: NoteEntityService,
|
||||
) {
|
||||
super(id, connection);
|
||||
super(request);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -61,24 +63,3 @@ class MainChannel extends Channel {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MainChannelService implements MiChannelService<true> {
|
||||
public readonly shouldShare = MainChannel.shouldShare;
|
||||
public readonly requireCredential = MainChannel.requireCredential;
|
||||
public readonly kind = MainChannel.kind;
|
||||
|
||||
constructor(
|
||||
private noteEntityService: NoteEntityService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public create(id: string, connection: Channel['connection']): MainChannel {
|
||||
return new MainChannel(
|
||||
this.noteEntityService,
|
||||
id,
|
||||
connection,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,21 +4,26 @@
|
||||
*/
|
||||
|
||||
import Xev from 'xev';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, Scope } from '@nestjs/common';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { isJsonObject } from '@/misc/json-value.js';
|
||||
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
|
||||
import Channel, { type MiChannelService } from '../channel.js';
|
||||
import Channel, { type ChannelRequest } from '../channel.js';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
|
||||
const ev = new Xev();
|
||||
|
||||
class QueueStatsChannel extends Channel {
|
||||
@Injectable({ scope: Scope.TRANSIENT })
|
||||
export class QueueStatsChannel extends Channel {
|
||||
public readonly chName = 'queueStats';
|
||||
public static shouldShare = true;
|
||||
public static requireCredential = false as const;
|
||||
|
||||
constructor(id: string, connection: Channel['connection']) {
|
||||
super(id, connection);
|
||||
constructor(
|
||||
@Inject(REQUEST)
|
||||
request: ChannelRequest,
|
||||
) {
|
||||
super(request);
|
||||
//this.onStats = this.onStats.bind(this);
|
||||
//this.onMessage = this.onMessage.bind(this);
|
||||
}
|
||||
@@ -56,22 +61,3 @@ class QueueStatsChannel extends Channel {
|
||||
ev.removeListener('queueStats', this.onStats);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class QueueStatsChannelService implements MiChannelService<false> {
|
||||
public readonly shouldShare = QueueStatsChannel.shouldShare;
|
||||
public readonly requireCredential = QueueStatsChannel.requireCredential;
|
||||
public readonly kind = QueueStatsChannel.kind;
|
||||
|
||||
constructor(
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public create(id: string, connection: Channel['connection']): QueueStatsChannel {
|
||||
return new QueueStatsChannel(
|
||||
id,
|
||||
connection,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,31 +3,32 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, Scope } from '@nestjs/common';
|
||||
import type { MiReversiGame } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { ReversiService } from '@/core/ReversiService.js';
|
||||
import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js';
|
||||
import { isJsonObject } from '@/misc/json-value.js';
|
||||
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
|
||||
import Channel, { type MiChannelService } from '../channel.js';
|
||||
import Channel, { type ChannelRequest } from '../channel.js';
|
||||
import { reversiUpdateKeys } from 'misskey-js';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
|
||||
class ReversiGameChannel extends Channel {
|
||||
@Injectable({ scope: Scope.TRANSIENT })
|
||||
export class ReversiGameChannel extends Channel {
|
||||
public readonly chName = 'reversiGame';
|
||||
public static shouldShare = false;
|
||||
public static requireCredential = false as const;
|
||||
private gameId: MiReversiGame['id'] | null = null;
|
||||
|
||||
constructor(
|
||||
@Inject(REQUEST)
|
||||
request: ChannelRequest,
|
||||
|
||||
private reversiService: ReversiService,
|
||||
private reversiGameEntityService: ReversiGameEntityService,
|
||||
|
||||
id: string,
|
||||
connection: Channel['connection'],
|
||||
) {
|
||||
super(id, connection);
|
||||
super(request);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -107,25 +108,3 @@ class ReversiGameChannel extends Channel {
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ReversiGameChannelService implements MiChannelService<false> {
|
||||
public readonly shouldShare = ReversiGameChannel.shouldShare;
|
||||
public readonly requireCredential = ReversiGameChannel.requireCredential;
|
||||
public readonly kind = ReversiGameChannel.kind;
|
||||
|
||||
constructor(
|
||||
private reversiService: ReversiService,
|
||||
private reversiGameEntityService: ReversiGameEntityService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public create(id: string, connection: Channel['connection']): ReversiGameChannel {
|
||||
return new ReversiGameChannel(
|
||||
this.reversiService,
|
||||
this.reversiGameEntityService,
|
||||
id,
|
||||
connection,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,22 +3,24 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, Scope } from '@nestjs/common';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { JsonObject } from '@/misc/json-value.js';
|
||||
import Channel, { type MiChannelService } from '../channel.js';
|
||||
import Channel, { type ChannelRequest } from '../channel.js';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
|
||||
class ReversiChannel extends Channel {
|
||||
@Injectable({ scope: Scope.TRANSIENT })
|
||||
export class ReversiChannel extends Channel {
|
||||
public readonly chName = 'reversi';
|
||||
public static shouldShare = true;
|
||||
public static requireCredential = true as const;
|
||||
public static kind = 'read:account';
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
connection: Channel['connection'],
|
||||
@Inject(REQUEST)
|
||||
request: ChannelRequest,
|
||||
) {
|
||||
super(id, connection);
|
||||
super(request);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -32,22 +34,3 @@ class ReversiChannel extends Channel {
|
||||
this.subscriber.off(`reversiStream:${this.user!.id}`, this.send);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ReversiChannelService implements MiChannelService<true> {
|
||||
public readonly shouldShare = ReversiChannel.shouldShare;
|
||||
public readonly requireCredential = ReversiChannel.requireCredential;
|
||||
public readonly kind = ReversiChannel.kind;
|
||||
|
||||
constructor(
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public create(id: string, connection: Channel['connection']): ReversiChannel {
|
||||
return new ReversiChannel(
|
||||
id,
|
||||
connection,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,28 +3,30 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, Scope } from '@nestjs/common';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
import type { JsonObject } from '@/misc/json-value.js';
|
||||
import Channel, { type MiChannelService } from '../channel.js';
|
||||
import Channel, { type ChannelRequest } from '../channel.js';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
|
||||
class RoleTimelineChannel extends Channel {
|
||||
@Injectable({ scope: Scope.TRANSIENT })
|
||||
export class RoleTimelineChannel extends Channel {
|
||||
public readonly chName = 'roleTimeline';
|
||||
public static shouldShare = false;
|
||||
public static requireCredential = false as const;
|
||||
private roleId: string;
|
||||
|
||||
constructor(
|
||||
@Inject(REQUEST)
|
||||
request: ChannelRequest,
|
||||
|
||||
private noteEntityService: NoteEntityService,
|
||||
private roleservice: RoleService,
|
||||
|
||||
id: string,
|
||||
connection: Channel['connection'],
|
||||
) {
|
||||
super(id, connection);
|
||||
super(request);
|
||||
//this.onNote = this.onNote.bind(this);
|
||||
}
|
||||
|
||||
@@ -60,26 +62,3 @@ class RoleTimelineChannel extends Channel {
|
||||
this.subscriber.off(`roleTimelineStream:${this.roleId}`, this.onEvent);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RoleTimelineChannelService implements MiChannelService<false> {
|
||||
public readonly shouldShare = RoleTimelineChannel.shouldShare;
|
||||
public readonly requireCredential = RoleTimelineChannel.requireCredential;
|
||||
public readonly kind = RoleTimelineChannel.kind;
|
||||
|
||||
constructor(
|
||||
private noteEntityService: NoteEntityService,
|
||||
private roleservice: RoleService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public create(id: string, connection: Channel['connection']): RoleTimelineChannel {
|
||||
return new RoleTimelineChannel(
|
||||
this.noteEntityService,
|
||||
this.roleservice,
|
||||
id,
|
||||
connection,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,21 +4,26 @@
|
||||
*/
|
||||
|
||||
import Xev from 'xev';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, Scope } from '@nestjs/common';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { isJsonObject } from '@/misc/json-value.js';
|
||||
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
|
||||
import Channel, { type MiChannelService } from '../channel.js';
|
||||
import Channel, { type ChannelRequest } from '../channel.js';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
|
||||
const ev = new Xev();
|
||||
|
||||
class ServerStatsChannel extends Channel {
|
||||
@Injectable({ scope: Scope.TRANSIENT })
|
||||
export class ServerStatsChannel extends Channel {
|
||||
public readonly chName = 'serverStats';
|
||||
public static shouldShare = true;
|
||||
public static requireCredential = false as const;
|
||||
|
||||
constructor(id: string, connection: Channel['connection']) {
|
||||
super(id, connection);
|
||||
constructor(
|
||||
@Inject(REQUEST)
|
||||
request: ChannelRequest,
|
||||
) {
|
||||
super(request);
|
||||
//this.onStats = this.onStats.bind(this);
|
||||
//this.onMessage = this.onMessage.bind(this);
|
||||
}
|
||||
@@ -54,22 +59,3 @@ class ServerStatsChannel extends Channel {
|
||||
ev.removeListener('serverStats', this.onStats);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ServerStatsChannelService implements MiChannelService<false> {
|
||||
public readonly shouldShare = ServerStatsChannel.shouldShare;
|
||||
public readonly requireCredential = ServerStatsChannel.requireCredential;
|
||||
public readonly kind = ServerStatsChannel.kind;
|
||||
|
||||
constructor(
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public create(id: string, connection: Channel['connection']): ServerStatsChannel {
|
||||
return new ServerStatsChannel(
|
||||
id,
|
||||
connection,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, Scope } from '@nestjs/common';
|
||||
import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
@@ -11,9 +11,11 @@ import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
|
||||
import type { JsonObject } from '@/misc/json-value.js';
|
||||
import Channel, { type MiChannelService } from '../channel.js';
|
||||
import Channel, { type ChannelRequest } from '../channel.js';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
|
||||
class UserListChannel extends Channel {
|
||||
@Injectable({ scope: Scope.TRANSIENT })
|
||||
export class UserListChannel extends Channel {
|
||||
public readonly chName = 'userList';
|
||||
public static shouldShare = false;
|
||||
public static requireCredential = false as const;
|
||||
@@ -24,14 +26,18 @@ class UserListChannel extends Channel {
|
||||
private withRenotes: boolean;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.userListsRepository)
|
||||
private userListsRepository: UserListsRepository,
|
||||
private userListMembershipsRepository: UserListMembershipsRepository,
|
||||
private noteEntityService: NoteEntityService,
|
||||
|
||||
id: string,
|
||||
connection: Channel['connection'],
|
||||
@Inject(DI.userListMembershipsRepository)
|
||||
private userListMembershipsRepository: UserListMembershipsRepository,
|
||||
|
||||
@Inject(REQUEST)
|
||||
request: ChannelRequest,
|
||||
|
||||
private noteEntityService: NoteEntityService,
|
||||
) {
|
||||
super(id, connection);
|
||||
super(request);
|
||||
//this.updateListUsers = this.updateListUsers.bind(this);
|
||||
//this.onNote = this.onNote.bind(this);
|
||||
}
|
||||
@@ -130,32 +136,3 @@ class UserListChannel extends Channel {
|
||||
clearInterval(this.listUsersClock);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UserListChannelService implements MiChannelService<false> {
|
||||
public readonly shouldShare = UserListChannel.shouldShare;
|
||||
public readonly requireCredential = UserListChannel.requireCredential;
|
||||
public readonly kind = UserListChannel.kind;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.userListsRepository)
|
||||
private userListsRepository: UserListsRepository,
|
||||
|
||||
@Inject(DI.userListMembershipsRepository)
|
||||
private userListMembershipsRepository: UserListMembershipsRepository,
|
||||
|
||||
private noteEntityService: NoteEntityService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public create(id: string, connection: Channel['connection']): UserListChannel {
|
||||
return new UserListChannel(
|
||||
this.userListsRepository,
|
||||
this.userListMembershipsRepository,
|
||||
this.noteEntityService,
|
||||
id,
|
||||
connection,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,41 +123,84 @@ function parseMicroformats(doc: htmlParser.HTMLElement, baseUrl: string, id: str
|
||||
return { name, logo };
|
||||
}
|
||||
|
||||
// https://indieauth.spec.indieweb.org/#client-information-discovery
|
||||
// "Authorization servers SHOULD support parsing the [h-app] Microformat from the client_id,
|
||||
// and if there is an [h-app] with a url property matching the client_id URL,
|
||||
// then it should use the name and icon and display them on the authorization prompt."
|
||||
// (But we don't display any icon for now)
|
||||
// https://indieauth.spec.indieweb.org/#redirect-url
|
||||
// "The client SHOULD publish one or more <link> tags or Link HTTP headers with a rel attribute
|
||||
// of redirect_uri at the client_id URL.
|
||||
// Authorization endpoints verifying that a redirect_uri is allowed for use by a client MUST
|
||||
// look for an exact match of the given redirect_uri in the request against the list of
|
||||
// redirect_uris discovered after resolving any relative URLs."
|
||||
async function discoverClientInformation(logger: Logger, httpRequestService: HttpRequestService, id: string): Promise<ClientInformation> {
|
||||
try {
|
||||
const res = await httpRequestService.send(id);
|
||||
const redirectUris: string[] = [];
|
||||
|
||||
const redirectUris: string[] = [];
|
||||
let name = id;
|
||||
let logo: string | null = null;
|
||||
|
||||
// https://indieauth.spec.indieweb.org/#redirect-url
|
||||
// "The client SHOULD publish one or more <link> tags or Link HTTP headers with a rel attribute
|
||||
// of redirect_uri at the client_id URL.
|
||||
// Authorization endpoints verifying that a redirect_uri is allowed for use by a client MUST
|
||||
// look for an exact match of the given redirect_uri in the request against the list of
|
||||
// redirect_uris discovered after resolving any relative URLs."
|
||||
const linkHeader = res.headers.get('link');
|
||||
if (linkHeader) {
|
||||
redirectUris.push(...httpLinkHeader.parse(linkHeader).get('rel', 'redirect_uri').map(r => r.uri));
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
const doc = htmlParser.parse(`<div>${text}</div>`);
|
||||
if (res.headers.get('content-type')?.includes('application/json')) {
|
||||
// Client discovery via JSON document (11 July 2024 spec)
|
||||
// https://indieauth.spec.indieweb.org/#client-metadata
|
||||
// "Clients SHOULD have a JSON [RFC7159] document at their client_id URL containing
|
||||
// client metadata defined in [RFC7591], the minimum properties for an IndieAuth
|
||||
// client defined below."
|
||||
|
||||
redirectUris.push(...[...doc.querySelectorAll('link[rel=redirect_uri][href]')].map(el => el.attributes.href));
|
||||
const json = await res.json() as {
|
||||
client_id: string;
|
||||
client_name?: string;
|
||||
client_uri: string;
|
||||
logo_uri?: string;
|
||||
redirect_uris?: string[];
|
||||
};
|
||||
|
||||
let name = id;
|
||||
let logo: string | null = null;
|
||||
if (text) {
|
||||
const microformats = parseMicroformats(doc, res.url, id);
|
||||
if (typeof microformats.name === 'string') {
|
||||
name = microformats.name;
|
||||
// https://indieauth.spec.indieweb.org/#client-metadata-li-1
|
||||
// "The authorization server MUST verify that the client_id in the document matches the
|
||||
// client_id of the URL where the document was retrieved."
|
||||
if (json.client_id !== id) {
|
||||
throw new AuthorizationError('client_id in the document does not match the client_id URL', 'invalid_request');
|
||||
}
|
||||
if (typeof microformats.logo === 'string') {
|
||||
logo = microformats.logo;
|
||||
|
||||
// https://indieauth.spec.indieweb.org/#client-metadata-li-1
|
||||
// "The client_uri MUST be a prefix of the client_id."
|
||||
if (!json.client_uri || !id.startsWith(json.client_uri)) {
|
||||
throw new AuthorizationError('client_uri is not a prefix of client_id', 'invalid_request');
|
||||
}
|
||||
|
||||
if (typeof json.client_name === 'string') {
|
||||
name = json.client_name;
|
||||
}
|
||||
|
||||
if (typeof json.logo_uri === 'string') {
|
||||
// Since uri can be relative, resolve it against the document URL
|
||||
logo = new URL(json.logo_uri, res.url).toString();
|
||||
}
|
||||
|
||||
if (Array.isArray(json.redirect_uris)) {
|
||||
redirectUris.push(...json.redirect_uris.filter((uri): uri is string => typeof uri === 'string'));
|
||||
}
|
||||
} else {
|
||||
// Client discovery via HTML microformats (12 February 2022 spec)
|
||||
// https://indieauth.spec.indieweb.org/20220212/#client-information-discovery
|
||||
// "Authorization servers SHOULD support parsing the [h-app] Microformat from the client_id,
|
||||
// and if there is an [h-app] with a url property matching the client_id URL,
|
||||
// then it should use the name and icon and display them on the authorization prompt."
|
||||
const text = await res.text();
|
||||
const doc = htmlParser.parse(`<div>${text}</div>`);
|
||||
|
||||
redirectUris.push(...[...doc.querySelectorAll('link[rel=redirect_uri][href]')].map(el => el.attributes.href));
|
||||
|
||||
if (text) {
|
||||
const microformats = parseMicroformats(doc, res.url, id);
|
||||
if (typeof microformats.name === 'string') {
|
||||
name = microformats.name;
|
||||
}
|
||||
if (typeof microformats.logo === 'string') {
|
||||
logo = microformats.logo;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +215,8 @@ async function discoverClientInformation(logger: Logger, httpRequestService: Htt
|
||||
logger.error('Error while fetching client information', { err });
|
||||
if (err instanceof StatusError) {
|
||||
throw new AuthorizationError('Failed to fetch client information', 'invalid_request');
|
||||
} else if (err instanceof AuthorizationError) {
|
||||
throw err;
|
||||
} else {
|
||||
throw new AuthorizationError('Failed to parse client information', 'server_error');
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { dirname } from 'node:path';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as fs from 'node:fs';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import ms from 'ms';
|
||||
import sharp from 'sharp';
|
||||
@@ -69,13 +70,28 @@ import type { FastifyError, FastifyInstance, FastifyPluginOptions, FastifyReply
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
|
||||
const staticAssets = `${_dirname}/../../../assets/`;
|
||||
const clientAssets = `${_dirname}/../../../../frontend/assets/`;
|
||||
const assets = `${_dirname}/../../../../../built/_frontend_dist_/`;
|
||||
const swAssets = `${_dirname}/../../../../../built/_sw_dist_/`;
|
||||
const frontendViteOut = `${_dirname}/../../../../../built/_frontend_vite_/`;
|
||||
const frontendEmbedViteOut = `${_dirname}/../../../../../built/_frontend_embed_vite_/`;
|
||||
const tarball = `${_dirname}/../../../../../built/tarball/`;
|
||||
let rootDir = _dirname;
|
||||
// 見つかるまで上に遡る
|
||||
while (!fs.existsSync(resolve(rootDir, 'packages'))) {
|
||||
const parentDir = dirname(rootDir);
|
||||
if (parentDir === rootDir) {
|
||||
throw new Error('Cannot find root directory');
|
||||
}
|
||||
rootDir = parentDir;
|
||||
}
|
||||
|
||||
const backendRootDir = resolve(rootDir, 'packages/backend');
|
||||
const frontendRootDir = resolve(rootDir, 'packages/frontend');
|
||||
|
||||
const staticAssets = resolve(backendRootDir, 'assets');
|
||||
const clientAssets = resolve(frontendRootDir, 'assets');
|
||||
const assets = resolve(rootDir, 'built/_frontend_dist_');
|
||||
const swAssets = resolve(rootDir, 'built/_sw_dist_');
|
||||
const fluentEmojisDir = resolve(rootDir, 'fluent-emojis/dist');
|
||||
const twemojiDir = resolve(backendRootDir, 'node_modules/@discordapp/twemoji/dist/svg');
|
||||
const frontendViteOut = resolve(rootDir, 'built/_frontend_vite_');
|
||||
const frontendEmbedViteOut = resolve(rootDir, 'built/_frontend_embed_vite_');
|
||||
const tarball = resolve(rootDir, 'built/tarball');
|
||||
|
||||
@Injectable()
|
||||
export class ClientServerService {
|
||||
@@ -207,6 +223,7 @@ export class ClientServerService {
|
||||
|
||||
//#region vite assets
|
||||
if (this.config.frontendEmbedManifestExists) {
|
||||
console.log(`[ClientServerService] Using built frontend vite assets. ${frontendViteOut}`);
|
||||
fastify.register((fastify, options, done) => {
|
||||
fastify.register(fastifyStatic, {
|
||||
root: frontendViteOut,
|
||||
@@ -226,6 +243,7 @@ export class ClientServerService {
|
||||
done();
|
||||
});
|
||||
} else {
|
||||
console.log('[ClientServerService] Proxying to Vite dev server.');
|
||||
const urlOriginWithoutPort = configUrl.origin.replace(/:\d+$/, '');
|
||||
|
||||
const port = (process.env.VITE_PORT ?? '5173');
|
||||
@@ -297,7 +315,7 @@ export class ClientServerService {
|
||||
|
||||
reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\'');
|
||||
|
||||
return await reply.sendFile(path, `${_dirname}/../../../../../fluent-emojis/dist/`, {
|
||||
return reply.sendFile(path, fluentEmojisDir, {
|
||||
maxAge: ms('30 days'),
|
||||
});
|
||||
});
|
||||
@@ -312,7 +330,7 @@ export class ClientServerService {
|
||||
|
||||
reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\'');
|
||||
|
||||
return await reply.sendFile(path, `${_dirname}/../../../node_modules/@discordapp/twemoji/dist/svg/`, {
|
||||
return reply.sendFile(path, twemojiDir, {
|
||||
maxAge: ms('30 days'),
|
||||
});
|
||||
});
|
||||
@@ -326,7 +344,7 @@ export class ClientServerService {
|
||||
}
|
||||
|
||||
const mask = await sharp(
|
||||
`${_dirname}/../../../node_modules/@discordapp/twemoji/dist/svg/${path.replace('.png', '')}.svg`,
|
||||
`${twemojiDir}/${path.replace('.png', '')}.svg`,
|
||||
{ density: 1000 },
|
||||
)
|
||||
.resize(488, 488)
|
||||
|
||||
@@ -34,6 +34,10 @@ services:
|
||||
source: ../built
|
||||
target: /misskey/packages/backend/built
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../src-js
|
||||
target: /misskey/packages/backend/src-js
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../migration
|
||||
target: /misskey/packages/backend/migration
|
||||
|
||||
@@ -143,7 +143,7 @@ services:
|
||||
bash -c "
|
||||
npm install -g pnpm
|
||||
pnpm -F backend i --frozen-lockfile
|
||||
pnpm exec tsc -p ./packages/backend/test-federation
|
||||
pnpm exec tsgo -p ./packages/backend/test-federation
|
||||
node ./packages/backend/test-federation/built/daemon.js
|
||||
"
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"experimental": {
|
||||
"keepImportAssertions": true
|
||||
},
|
||||
"baseUrl": "../built",
|
||||
"baseUrl": "../src-js",
|
||||
"paths": {
|
||||
"@/*": ["*"]
|
||||
},
|
||||
|
||||
@@ -11,3 +11,11 @@ services:
|
||||
environment:
|
||||
POSTGRES_DB: "test-misskey"
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
|
||||
meilisearchtest:
|
||||
image: getmeili/meilisearch:v1.3.4
|
||||
ports:
|
||||
- "127.0.0.1:57712:7700"
|
||||
environment:
|
||||
- MEILI_NO_ANALYTICS=true
|
||||
- MEILI_ENV=development
|
||||
|
||||
@@ -28,6 +28,7 @@ const host = `http://127.0.0.1:${port}`;
|
||||
|
||||
const clientPort = port + 1;
|
||||
const redirect_uri = `http://127.0.0.1:${clientPort}/redirect`;
|
||||
const redirect_uri2 = `http://127.0.0.1:${clientPort}/redirect2`;
|
||||
|
||||
const basicAuthParams: AuthorizationParamsExtended = {
|
||||
redirect_uri,
|
||||
@@ -807,65 +808,19 @@ describe('OAuth', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// https://indieauth.spec.indieweb.org/#client-information-discovery
|
||||
describe('Client Information Discovery', () => {
|
||||
describe('Redirection', () => {
|
||||
const tests: Record<string, (reply: FastifyReply) => void> = {
|
||||
'Read HTTP header': reply => {
|
||||
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<div class="h-app"><a href="/" class="u-url p-name">Misklient
|
||||
`);
|
||||
},
|
||||
'Mixed links': reply => {
|
||||
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<link rel="redirect_uri" href="/redirect2" />
|
||||
<div class="h-app"><a href="/" class="u-url p-name">Misklient
|
||||
`);
|
||||
},
|
||||
'Multiple items in Link header': reply => {
|
||||
reply.header('Link', '</redirect2>; rel="redirect_uri",</redirect>; rel="redirect_uri"');
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<div class="h-app"><a href="/" class="u-url p-name">Misklient
|
||||
`);
|
||||
},
|
||||
'Multiple items in HTML': reply => {
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<link rel="redirect_uri" href="/redirect2" />
|
||||
<link rel="redirect_uri" href="/redirect" />
|
||||
<div class="h-app"><a href="/" class="u-url p-name">Misklient
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
||||
for (const [title, replyFunc] of Object.entries(tests)) {
|
||||
test(title, async () => {
|
||||
sender = replyFunc;
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(response.status, 200);
|
||||
});
|
||||
}
|
||||
|
||||
test('No item', async () => {
|
||||
// https://indieauth.spec.indieweb.org/#client-information-discovery
|
||||
describe('JSON client metadata (11 July 2024)', () => {
|
||||
test('Read JSON document', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<div class="h-app"><a href="/" class="u-url p-name">Misklient
|
||||
`);
|
||||
reply.header('content-type', 'application/json');
|
||||
reply.send({
|
||||
client_id: `http://127.0.0.1:${clientPort}/`,
|
||||
client_uri: `http://127.0.0.1:${clientPort}/`,
|
||||
client_name: 'Misklient JSON',
|
||||
logo_uri: '/logo.png',
|
||||
redirect_uris: ['/redirect'],
|
||||
});
|
||||
};
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
@@ -877,119 +832,294 @@ describe('OAuth', () => {
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(response.status, 200);
|
||||
const meta = getMeta(await response.text());
|
||||
assert.strictEqual(meta.clientName, 'Misklient JSON');
|
||||
assert.strictEqual(meta.clientLogo, `http://127.0.0.1:${clientPort}/logo.png`);
|
||||
});
|
||||
|
||||
// direct error because there's no redirect URI to ping
|
||||
test('Merge Link header redirect_uri with JSON redirect_uris', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.header('Link', '</redirect2>; rel="redirect_uri"');
|
||||
reply.header('content-type', 'application/json');
|
||||
reply.send({
|
||||
client_id: `http://127.0.0.1:${clientPort}/`,
|
||||
client_uri: `http://127.0.0.1:${clientPort}/`,
|
||||
client_name: 'Misklient JSON',
|
||||
redirect_uris: ['/redirect'],
|
||||
});
|
||||
};
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const ok1 = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(ok1.status, 200);
|
||||
|
||||
const ok2 = await fetch(client.authorizeURL({
|
||||
redirect_uri: redirect_uri2,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(ok2.status, 200);
|
||||
});
|
||||
|
||||
test('Reject when client_id does not match retrieved URL', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.header('content-type', 'application/json');
|
||||
reply.send({
|
||||
client_id: `http://127.0.0.1:${clientPort}/mismatch`,
|
||||
client_uri: `http://127.0.0.1:${clientPort}/`,
|
||||
redirect_uris: ['/redirect'],
|
||||
});
|
||||
};
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
await assertDirectError(response, 400, 'invalid_request');
|
||||
});
|
||||
|
||||
test('Reject when client_uri is not a prefix of client_id', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.header('content-type', 'application/json');
|
||||
reply.send({
|
||||
client_id: `http://127.0.0.1:${clientPort}/`,
|
||||
client_uri: `http://127.0.0.1:${clientPort}/no-prefix/`,
|
||||
redirect_uris: ['/redirect'],
|
||||
});
|
||||
};
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
await assertDirectError(response, 400, 'invalid_request');
|
||||
});
|
||||
|
||||
test('Reject when JSON metadata has no redirect_uris and no Link header', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.header('content-type', 'application/json');
|
||||
reply.send({
|
||||
client_id: `http://127.0.0.1:${clientPort}/`,
|
||||
client_uri: `http://127.0.0.1:${clientPort}/`,
|
||||
client_name: 'Misklient JSON',
|
||||
});
|
||||
};
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
await assertDirectError(response, 400, 'invalid_request');
|
||||
});
|
||||
});
|
||||
|
||||
test('Disallow loopback', async () => {
|
||||
await sendEnvUpdateRequest({ key: 'MISSKEY_TEST_CHECK_IP_RANGE', value: '1' });
|
||||
// https://indieauth.spec.indieweb.org/20220212/#client-information-discovery
|
||||
describe('HTML link client metadata (12 Feb 2022)', () => {
|
||||
describe('Redirection', () => {
|
||||
const tests: Record<string, (reply: FastifyReply) => void> = {
|
||||
'Read HTTP header': reply => {
|
||||
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<div class="h-app"><a href="/" class="u-url p-name">Misklient
|
||||
`);
|
||||
},
|
||||
'Mixed links': reply => {
|
||||
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<link rel="redirect_uri" href="/redirect2" />
|
||||
<div class="h-app"><a href="/" class="u-url p-name">Misklient
|
||||
`);
|
||||
},
|
||||
'Multiple items in Link header': reply => {
|
||||
reply.header('Link', '</redirect2>; rel="redirect_uri",</redirect>; rel="redirect_uri"');
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<div class="h-app"><a href="/" class="u-url p-name">Misklient
|
||||
`);
|
||||
},
|
||||
'Multiple items in HTML': reply => {
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<link rel="redirect_uri" href="/redirect2" />
|
||||
<link rel="redirect_uri" href="/redirect" />
|
||||
<div class="h-app"><a href="/" class="u-url p-name">Misklient
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
await assertDirectError(response, 400, 'invalid_request');
|
||||
});
|
||||
for (const [title, replyFunc] of Object.entries(tests)) {
|
||||
test(title, async () => {
|
||||
sender = replyFunc;
|
||||
|
||||
test('Missing name', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||
reply.send();
|
||||
};
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(response.status, 200);
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(response.status, 200);
|
||||
assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`);
|
||||
});
|
||||
test('No item', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<div class="h-app"><a href="/" class="u-url p-name">Misklient
|
||||
`);
|
||||
};
|
||||
|
||||
test('With Logo', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<div class="h-app">
|
||||
<a href="/" class="u-url p-name">Misklient</a>
|
||||
<img src="/logo.png" class="u-logo" />
|
||||
</div>
|
||||
`);
|
||||
reply.send();
|
||||
};
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(response.status, 200);
|
||||
const meta = getMeta(await response.text());
|
||||
assert.strictEqual(meta.clientName, 'Misklient');
|
||||
assert.strictEqual(meta.clientLogo, `http://127.0.0.1:${clientPort}/logo.png`);
|
||||
});
|
||||
// direct error because there's no redirect URI to ping
|
||||
await assertDirectError(response, 400, 'invalid_request');
|
||||
});
|
||||
});
|
||||
|
||||
test('Missing Logo', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<div class="h-app"><a href="/" class="u-url p-name">Misklient
|
||||
`);
|
||||
reply.send();
|
||||
};
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
test('Disallow loopback', async () => {
|
||||
await sendEnvUpdateRequest({ key: 'MISSKEY_TEST_CHECK_IP_RANGE', value: '1' });
|
||||
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(response.status, 200);
|
||||
const meta = getMeta(await response.text());
|
||||
assert.strictEqual(meta.clientName, 'Misklient');
|
||||
assert.strictEqual(meta.clientLogo, undefined);
|
||||
});
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
await assertDirectError(response, 400, 'invalid_request');
|
||||
});
|
||||
|
||||
test('Mismatching URL in h-app', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<div class="h-app"><a href="/foo" class="u-url p-name">Misklient
|
||||
`);
|
||||
reply.send();
|
||||
};
|
||||
test('Missing name', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||
reply.send();
|
||||
};
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(response.status, 200);
|
||||
assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`);
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(response.status, 200);
|
||||
assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`);
|
||||
});
|
||||
|
||||
test('With Logo', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<div class="h-app">
|
||||
<a href="/" class="u-url p-name">Misklient</a>
|
||||
<img src="/logo.png" class="u-logo" />
|
||||
</div>
|
||||
`);
|
||||
reply.send();
|
||||
};
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(response.status, 200);
|
||||
const meta = getMeta(await response.text());
|
||||
assert.strictEqual(meta.clientName, 'Misklient');
|
||||
assert.strictEqual(meta.clientLogo, `http://127.0.0.1:${clientPort}/logo.png`);
|
||||
});
|
||||
|
||||
test('Missing Logo', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<div class="h-app"><a href="/" class="u-url p-name">Misklient
|
||||
`);
|
||||
reply.send();
|
||||
};
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(response.status, 200);
|
||||
const meta = getMeta(await response.text());
|
||||
assert.strictEqual(meta.clientName, 'Misklient');
|
||||
assert.strictEqual(meta.clientLogo, undefined);
|
||||
});
|
||||
|
||||
test('Mismatching URL in h-app', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<div class="h-app"><a href="/foo" class="u-url p-name">Misklient
|
||||
`);
|
||||
reply.send();
|
||||
};
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(response.status, 200);
|
||||
assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "@kitajs/html",
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["../src/*"]
|
||||
},
|
||||
|
||||
483
packages/backend/test/unit/SearchService.ts
Normal file
483
packages/backend/test/unit/SearchService.ts
Normal file
@@ -0,0 +1,483 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, test } from '@jest/globals';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import type { Index, MeiliSearch } from 'meilisearch';
|
||||
import { type Config, loadConfig } from '@/config.js';
|
||||
import { GlobalModule } from '@/GlobalModule.js';
|
||||
import { CoreModule } from '@/core/CoreModule.js';
|
||||
import { SearchService } from '@/core/SearchService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import {
|
||||
type BlockingsRepository,
|
||||
type ChannelsRepository,
|
||||
type FollowingsRepository,
|
||||
type MutingsRepository,
|
||||
type NotesRepository,
|
||||
type UserProfilesRepository,
|
||||
type UsersRepository,
|
||||
type MiChannel,
|
||||
type MiNote,
|
||||
type MiUser,
|
||||
} from '@/models/_.js';
|
||||
|
||||
describe('SearchService', () => {
|
||||
type TestContext = {
|
||||
app: TestingModule;
|
||||
service: SearchService;
|
||||
cacheService: CacheService;
|
||||
idService: IdService;
|
||||
mutingsRepository: MutingsRepository;
|
||||
blockingsRepository: BlockingsRepository;
|
||||
usersRepository: UsersRepository;
|
||||
userProfilesRepository: UserProfilesRepository;
|
||||
notesRepository: NotesRepository;
|
||||
channelsRepository: ChannelsRepository;
|
||||
followingsRepository: FollowingsRepository;
|
||||
indexer?: (note: MiNote) => Promise<void>;
|
||||
};
|
||||
|
||||
const meilisearchSettings = {
|
||||
searchableAttributes: [
|
||||
'text',
|
||||
'cw',
|
||||
],
|
||||
sortableAttributes: [
|
||||
'createdAt',
|
||||
],
|
||||
filterableAttributes: [
|
||||
'createdAt',
|
||||
'userId',
|
||||
'userHost',
|
||||
'channelId',
|
||||
'tags',
|
||||
],
|
||||
typoTolerance: {
|
||||
enabled: false,
|
||||
},
|
||||
pagination: {
|
||||
maxTotalHits: 10000,
|
||||
},
|
||||
};
|
||||
|
||||
async function buildContext(configOverride?: Config): Promise<TestContext> {
|
||||
const builder = Test.createTestingModule({
|
||||
imports: [
|
||||
GlobalModule,
|
||||
CoreModule,
|
||||
],
|
||||
});
|
||||
|
||||
if (configOverride) {
|
||||
builder.overrideProvider(DI.config).useValue(configOverride);
|
||||
}
|
||||
|
||||
const app = await builder.compile();
|
||||
|
||||
app.enableShutdownHooks();
|
||||
|
||||
return {
|
||||
app,
|
||||
service: app.get(SearchService),
|
||||
cacheService: app.get(CacheService),
|
||||
idService: app.get(IdService),
|
||||
mutingsRepository: app.get(DI.mutingsRepository),
|
||||
blockingsRepository: app.get(DI.blockingsRepository),
|
||||
usersRepository: app.get(DI.usersRepository),
|
||||
userProfilesRepository: app.get(DI.userProfilesRepository),
|
||||
notesRepository: app.get(DI.notesRepository),
|
||||
channelsRepository: app.get(DI.channelsRepository),
|
||||
followingsRepository: app.get(DI.followingsRepository),
|
||||
};
|
||||
}
|
||||
|
||||
async function cleanupContext(ctx: TestContext) {
|
||||
await ctx.notesRepository.createQueryBuilder().delete().execute();
|
||||
await ctx.mutingsRepository.createQueryBuilder().delete().execute();
|
||||
await ctx.blockingsRepository.createQueryBuilder().delete().execute();
|
||||
await ctx.followingsRepository.createQueryBuilder().delete().execute();
|
||||
await ctx.channelsRepository.createQueryBuilder().delete().execute();
|
||||
await ctx.userProfilesRepository.createQueryBuilder().delete().execute();
|
||||
await ctx.usersRepository.createQueryBuilder().delete().execute();
|
||||
}
|
||||
|
||||
async function createUser(ctx: TestContext, data: Partial<MiUser> = {}) {
|
||||
const id = ctx.idService.gen();
|
||||
const username = data.username ?? `user_${id}`;
|
||||
const usernameLower = data.usernameLower ?? username.toLowerCase();
|
||||
|
||||
const user = await ctx.usersRepository
|
||||
.insert({
|
||||
id,
|
||||
username,
|
||||
usernameLower,
|
||||
...data,
|
||||
})
|
||||
.then(x => ctx.usersRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
await ctx.userProfilesRepository.insert({
|
||||
userId: id,
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async function createChannel(ctx: TestContext, user: MiUser, data: Partial<MiChannel> = {}) {
|
||||
const id = ctx.idService.gen();
|
||||
const channel = await ctx.channelsRepository
|
||||
.insert({
|
||||
id,
|
||||
userId: user.id,
|
||||
name: data.name ?? `channel_${id}`,
|
||||
...data,
|
||||
})
|
||||
.then(x => ctx.channelsRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
async function createNote(ctx: TestContext, user: MiUser, data: Partial<MiNote> = {}, time?: number) {
|
||||
const id = time == null ? ctx.idService.gen() : ctx.idService.gen(time);
|
||||
const note = await ctx.notesRepository
|
||||
.insert({
|
||||
id,
|
||||
text: 'hello',
|
||||
userId: user.id,
|
||||
userHost: user.host,
|
||||
visibility: 'public',
|
||||
tags: [],
|
||||
...data,
|
||||
})
|
||||
.then(x => ctx.notesRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
if (ctx.indexer) {
|
||||
await ctx.indexer(note);
|
||||
}
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
async function createFollowing(ctx: TestContext, follower: MiUser, followee: MiUser) {
|
||||
await ctx.followingsRepository.insert({
|
||||
id: ctx.idService.gen(),
|
||||
followerId: follower.id,
|
||||
followeeId: followee.id,
|
||||
followerHost: follower.host,
|
||||
followeeHost: followee.host,
|
||||
});
|
||||
}
|
||||
|
||||
function clearUserCaches(ctx: TestContext, userId: MiUser['id']) {
|
||||
ctx.cacheService.userMutingsCache.delete(userId);
|
||||
ctx.cacheService.userBlockedCache.delete(userId);
|
||||
ctx.cacheService.userBlockingCache.delete(userId);
|
||||
}
|
||||
|
||||
async function createMuting(ctx: TestContext, muter: MiUser, mutee: MiUser) {
|
||||
await ctx.mutingsRepository.insert({
|
||||
id: ctx.idService.gen(),
|
||||
muterId: muter.id,
|
||||
muteeId: mutee.id,
|
||||
});
|
||||
clearUserCaches(ctx, muter.id);
|
||||
}
|
||||
|
||||
async function createBlocking(ctx: TestContext, blocker: MiUser, blockee: MiUser) {
|
||||
await ctx.blockingsRepository.insert({
|
||||
id: ctx.idService.gen(),
|
||||
blockerId: blocker.id,
|
||||
blockeeId: blockee.id,
|
||||
});
|
||||
clearUserCaches(ctx, blocker.id);
|
||||
clearUserCaches(ctx, blockee.id);
|
||||
}
|
||||
|
||||
function defineSearchNoteTests(
|
||||
getCtx: () => TestContext,
|
||||
{
|
||||
supportsFollowersVisibility,
|
||||
sinceIdOrder,
|
||||
}: {
|
||||
supportsFollowersVisibility: boolean;
|
||||
sinceIdOrder: 'asc' | 'desc';
|
||||
},
|
||||
) {
|
||||
describe('searchNote', () => {
|
||||
test('filters notes by visibility (followers only visible to followers)', async () => {
|
||||
const ctx = getCtx();
|
||||
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
|
||||
const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null });
|
||||
|
||||
const publicNote = await createNote(ctx, author, { text: 'hello public', visibility: 'public' });
|
||||
const followersNote = await createNote(ctx, author, { text: 'hello followers', visibility: 'followers' });
|
||||
|
||||
const beforeFollow = await ctx.service.searchNote('hello', me, {}, { limit: 10 });
|
||||
expect(beforeFollow.map(note => note.id)).toEqual([publicNote.id]);
|
||||
|
||||
await createFollowing(ctx, me, author);
|
||||
|
||||
const afterFollow = await ctx.service.searchNote('hello', me, {}, { limit: 10 });
|
||||
const expectedIds = supportsFollowersVisibility
|
||||
? [followersNote.id, publicNote.id]
|
||||
: [publicNote.id];
|
||||
expect(afterFollow.map(note => note.id).sort()).toEqual(expectedIds.sort());
|
||||
});
|
||||
|
||||
test('filters out suspended users via base note filtering', async () => {
|
||||
const ctx = getCtx();
|
||||
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
|
||||
const active = await createUser(ctx, { username: 'active', usernameLower: 'active', host: null });
|
||||
const suspended = await createUser(ctx, { username: 'suspended', usernameLower: 'suspended', host: null, isSuspended: true });
|
||||
|
||||
const activeNote = await createNote(ctx, active, { text: 'hello active', visibility: 'public' });
|
||||
await createNote(ctx, suspended, { text: 'hello suspended', visibility: 'public' });
|
||||
|
||||
const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 });
|
||||
expect(result.map(note => note.id)).toEqual([activeNote.id]);
|
||||
});
|
||||
|
||||
test('filters by userId', async () => {
|
||||
const ctx = getCtx();
|
||||
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
|
||||
const alice = await createUser(ctx, { username: 'alice', usernameLower: 'alice', host: null });
|
||||
const bob = await createUser(ctx, { username: 'bob', usernameLower: 'bob', host: null });
|
||||
|
||||
const aliceNote = await createNote(ctx, alice, { text: 'hello alice', visibility: 'public' });
|
||||
await createNote(ctx, bob, { text: 'hello bob', visibility: 'public' });
|
||||
|
||||
const result = await ctx.service.searchNote('hello', me, { userId: alice.id }, { limit: 10 });
|
||||
expect(result.map(note => note.id)).toEqual([aliceNote.id]);
|
||||
});
|
||||
|
||||
test('filters by channelId', async () => {
|
||||
const ctx = getCtx();
|
||||
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
|
||||
const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null });
|
||||
const channelA = await createChannel(ctx, author, { name: 'channel-a' });
|
||||
const channelB = await createChannel(ctx, author, { name: 'channel-b' });
|
||||
|
||||
const channelNote = await createNote(ctx, author, { text: 'hello channel', channelId: channelA.id, visibility: 'public' });
|
||||
await createNote(ctx, author, { text: 'hello other', channelId: channelB.id, visibility: 'public' });
|
||||
|
||||
const result = await ctx.service.searchNote('hello', me, { channelId: channelA.id }, { limit: 10 });
|
||||
expect(result.map(note => note.id)).toEqual([channelNote.id]);
|
||||
});
|
||||
|
||||
test('filters by host', async () => {
|
||||
const ctx = getCtx();
|
||||
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
|
||||
const local = await createUser(ctx, { username: 'local', usernameLower: 'local', host: null });
|
||||
const remote = await createUser(ctx, { username: 'remote', usernameLower: 'remote', host: 'example.com' });
|
||||
|
||||
const localNote = await createNote(ctx, local, { text: 'hello local', visibility: 'public' });
|
||||
const remoteNote = await createNote(ctx, remote, { text: 'hello remote', visibility: 'public', userHost: 'example.com' });
|
||||
|
||||
const localResult = await ctx.service.searchNote('hello', me, { host: '.' }, { limit: 10 });
|
||||
expect(localResult.map(note => note.id)).toEqual([localNote.id]);
|
||||
|
||||
const remoteResult = await ctx.service.searchNote('hello', me, { host: 'example.com' }, { limit: 10 });
|
||||
expect(remoteResult.map(note => note.id)).toEqual([remoteNote.id]);
|
||||
});
|
||||
|
||||
describe('muting and blocking', () => {
|
||||
test('filters out muted users', async () => {
|
||||
const ctx = getCtx();
|
||||
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
|
||||
const muted = await createUser(ctx, { username: 'muted', usernameLower: 'muted', host: null });
|
||||
const other = await createUser(ctx, { username: 'other', usernameLower: 'other', host: null });
|
||||
|
||||
await createNote(ctx, muted, { text: 'hello muted', visibility: 'public' });
|
||||
const otherNote = await createNote(ctx, other, { text: 'hello other', visibility: 'public' });
|
||||
|
||||
await createMuting(ctx, me, muted);
|
||||
|
||||
const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 });
|
||||
|
||||
expect(result.map(note => note.id)).toEqual([otherNote.id]);
|
||||
});
|
||||
|
||||
test('filters out users who block me', async () => {
|
||||
const ctx = getCtx();
|
||||
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
|
||||
const blocker = await createUser(ctx, { username: 'blocker', usernameLower: 'blocker', host: null });
|
||||
const other = await createUser(ctx, { username: 'other', usernameLower: 'other', host: null });
|
||||
|
||||
await createNote(ctx, blocker, { text: 'hello blocker', visibility: 'public' });
|
||||
const otherNote = await createNote(ctx, other, { text: 'hello other', visibility: 'public' });
|
||||
|
||||
await createBlocking(ctx, blocker, me);
|
||||
|
||||
const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 });
|
||||
|
||||
expect(result.map(note => note.id)).toEqual([otherNote.id]);
|
||||
});
|
||||
|
||||
test('filters no out users I block', async () => {
|
||||
const ctx = getCtx();
|
||||
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
|
||||
const blocked = await createUser(ctx, { username: 'blocked', usernameLower: 'blocked', host: null });
|
||||
const other = await createUser(ctx, { username: 'other', usernameLower: 'other', host: null });
|
||||
|
||||
const blockedNote = await createNote(ctx, blocked, { text: 'hello blocked', visibility: 'public' });
|
||||
const otherNote = await createNote(ctx, other, { text: 'hello other', visibility: 'public' });
|
||||
|
||||
await createBlocking(ctx, me, blocked);
|
||||
|
||||
const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 });
|
||||
expect(result.map(note => note.id).sort()).toEqual([otherNote.id, blockedNote.id].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagination', () => {
|
||||
test('paginates with sinceId', async () => {
|
||||
const ctx = getCtx();
|
||||
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
|
||||
const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null });
|
||||
|
||||
const t1 = Date.now() - 3000;
|
||||
const t2 = Date.now() - 2000;
|
||||
const t3 = Date.now() - 1000;
|
||||
|
||||
const note1 = await createNote(ctx, author, { text: 'hello' }, t1);
|
||||
const note2 = await createNote(ctx, author, { text: 'hello' }, t2);
|
||||
const note3 = await createNote(ctx, author, { text: 'hello' }, t3);
|
||||
|
||||
const result = await ctx.service.searchNote('hello', me, {}, { limit: 10, sinceId: note1.id });
|
||||
|
||||
const expected = sinceIdOrder === 'asc'
|
||||
? [note2.id, note3.id]
|
||||
: [note3.id, note2.id];
|
||||
expect(result.map(note => note.id)).toEqual(expected);
|
||||
});
|
||||
|
||||
test('paginates with untilId', async () => {
|
||||
const ctx = getCtx();
|
||||
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
|
||||
const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null });
|
||||
|
||||
const t1 = Date.now() - 3000;
|
||||
const t2 = Date.now() - 2000;
|
||||
const t3 = Date.now() - 1000;
|
||||
|
||||
const note1 = await createNote(ctx, author, { text: 'hello' }, t1);
|
||||
const note2 = await createNote(ctx, author, { text: 'hello' }, t2);
|
||||
const note3 = await createNote(ctx, author, { text: 'hello' }, t3);
|
||||
|
||||
const result = await ctx.service.searchNote('hello', me, {}, { limit: 10, untilId: note3.id });
|
||||
|
||||
expect(result.map(note => note.id)).toEqual([note2.id, note1.id]);
|
||||
});
|
||||
|
||||
test('paginates with sinceId and untilId together', async () => {
|
||||
const ctx = getCtx();
|
||||
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
|
||||
const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null });
|
||||
|
||||
const t1 = Date.now() - 4000;
|
||||
const t2 = Date.now() - 3000;
|
||||
const t3 = Date.now() - 2000;
|
||||
const t4 = Date.now() - 1000;
|
||||
|
||||
const note1 = await createNote(ctx, author, { text: 'hello' }, t1);
|
||||
const note2 = await createNote(ctx, author, { text: 'hello' }, t2);
|
||||
const note3 = await createNote(ctx, author, { text: 'hello' }, t3);
|
||||
const note4 = await createNote(ctx, author, { text: 'hello' }, t4);
|
||||
|
||||
const result = await ctx.service.searchNote('hello', me, {}, { limit: 10, sinceId: note1.id, untilId: note4.id });
|
||||
|
||||
expect(result.map(note => note.id)).toEqual([note3.id, note2.id]);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('sqlLike', () => {
|
||||
let ctx: TestContext;
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await buildContext();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await ctx.app.close();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupContext(ctx);
|
||||
});
|
||||
|
||||
defineSearchNoteTests(() => ctx, { supportsFollowersVisibility: true, sinceIdOrder: 'asc' });
|
||||
});
|
||||
|
||||
describe('meilisearch', () => {
|
||||
let ctx: TestContext;
|
||||
let meilisearch: MeiliSearch;
|
||||
let meilisearchIndex: Index;
|
||||
let meiliConfig: Config;
|
||||
|
||||
beforeAll(async () => {
|
||||
const baseConfig = loadConfig();
|
||||
meiliConfig = {
|
||||
...baseConfig,
|
||||
fulltextSearch: {
|
||||
provider: 'meilisearch',
|
||||
},
|
||||
meilisearch: {
|
||||
host: '127.0.0.1',
|
||||
port: '57712',
|
||||
apiKey: '',
|
||||
index: 'test-search-service',
|
||||
scope: 'global',
|
||||
ssl: false,
|
||||
},
|
||||
};
|
||||
|
||||
ctx = await buildContext(meiliConfig);
|
||||
meilisearch = ctx.app.get(DI.meilisearch) as MeiliSearch;
|
||||
meilisearchIndex = meilisearch.index(`${meiliConfig.meilisearch!.index}---notes`);
|
||||
|
||||
const settingsTask = await meilisearchIndex.updateSettings(meilisearchSettings);
|
||||
await meilisearch.tasks.waitForTask(settingsTask.taskUid);
|
||||
|
||||
const clearTask = await meilisearchIndex.deleteAllDocuments();
|
||||
await meilisearch.tasks.waitForTask(clearTask.taskUid);
|
||||
|
||||
ctx.indexer = async (note: MiNote) => {
|
||||
if (note.text == null && note.cw == null) return;
|
||||
if (!['home', 'public'].includes(note.visibility)) return;
|
||||
if (meiliConfig.meilisearch?.scope === 'local' && note.userHost != null) return;
|
||||
|
||||
const task = await meilisearchIndex.addDocuments([{
|
||||
id: note.id,
|
||||
createdAt: ctx.idService.parse(note.id).date.getTime(),
|
||||
userId: note.userId,
|
||||
userHost: note.userHost,
|
||||
channelId: note.channelId,
|
||||
cw: note.cw,
|
||||
text: note.text,
|
||||
tags: note.tags,
|
||||
}], {
|
||||
primaryKey: 'id',
|
||||
});
|
||||
await meilisearch.tasks.waitForTask(task.taskUid);
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await ctx.app.close();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupContext(ctx);
|
||||
const clearTask = await meilisearchIndex.deleteAllDocuments();
|
||||
await meilisearch.tasks.waitForTask(clearTask.taskUid);
|
||||
});
|
||||
|
||||
defineSearchNoteTests(() => ctx, { supportsFollowersVisibility: false, sinceIdOrder: 'desc' });
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,6 @@
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "@kitajs/html",
|
||||
"rootDir": "./src",
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"eslint": "eslint './**/*.{js,jsx,ts,tsx}'",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"lint": "pnpm typecheck && pnpm eslint"
|
||||
},
|
||||
"exports": {
|
||||
@@ -11,16 +11,15 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/estree": "1.0.8",
|
||||
"@types/node": "24.10.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.49.0",
|
||||
"@typescript-eslint/parser": "8.49.0",
|
||||
"rollup": "4.53.3",
|
||||
"typescript": "5.9.3"
|
||||
"@types/node": "24.10.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.50.1",
|
||||
"@typescript-eslint/parser": "8.50.1",
|
||||
"rollup": "4.54.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18n": "workspace:*",
|
||||
"estree-walker": "3.0.3",
|
||||
"magic-string": "0.30.21",
|
||||
"vite": "7.2.7"
|
||||
"vite": "7.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
"noImplicitReturns": true,
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"baseUrl": ".",
|
||||
"typeRoots": [
|
||||
"./@types",
|
||||
"./node_modules/@types"
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"@rollup/plugin-replace": "6.0.3",
|
||||
"@rollup/pluginutils": "5.3.0",
|
||||
"@twemoji/parser": "16.0.0",
|
||||
"@vitejs/plugin-vue": "6.0.2",
|
||||
"@vitejs/plugin-vue": "6.0.3",
|
||||
"buraha": "0.0.1",
|
||||
"estree-walker": "3.0.3",
|
||||
"frontend-shared": "workspace:*",
|
||||
@@ -25,13 +25,13 @@
|
||||
"mfm-js": "0.25.0",
|
||||
"misskey-js": "workspace:*",
|
||||
"punycode.js": "2.3.1",
|
||||
"rollup": "4.53.3",
|
||||
"sass": "1.95.1",
|
||||
"shiki": "3.19.0",
|
||||
"rollup": "4.54.0",
|
||||
"sass": "1.97.1",
|
||||
"shiki": "3.20.0",
|
||||
"tinycolor2": "1.6.0",
|
||||
"uuid": "13.0.0",
|
||||
"vite": "7.2.7",
|
||||
"vue": "3.5.25"
|
||||
"vite": "7.3.0",
|
||||
"vue": "3.5.26"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@misskey-dev/summaly": "5.2.5",
|
||||
@@ -39,14 +39,14 @@
|
||||
"@testing-library/vue": "8.1.0",
|
||||
"@types/estree": "1.0.8",
|
||||
"@types/micromatch": "4.0.10",
|
||||
"@types/node": "24.10.2",
|
||||
"@types/node": "24.10.4",
|
||||
"@types/punycode.js": "npm:@types/punycode@2.1.4",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.49.0",
|
||||
"@typescript-eslint/parser": "8.49.0",
|
||||
"@vitest/coverage-v8": "4.0.15",
|
||||
"@vue/runtime-core": "3.5.25",
|
||||
"@typescript-eslint/eslint-plugin": "8.50.1",
|
||||
"@typescript-eslint/parser": "8.50.1",
|
||||
"@vitest/coverage-v8": "4.0.16",
|
||||
"@vue/runtime-core": "3.5.26",
|
||||
"acorn": "8.15.0",
|
||||
"cross-env": "10.1.0",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
@@ -54,14 +54,13 @@
|
||||
"happy-dom": "20.0.11",
|
||||
"intersection-observer": "0.12.2",
|
||||
"micromatch": "4.0.8",
|
||||
"msw": "2.12.4",
|
||||
"msw": "2.12.6",
|
||||
"nodemon": "3.1.11",
|
||||
"prettier": "3.7.4",
|
||||
"start-server-and-test": "2.1.3",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"vite-plugin-turbosnap": "1.0.3",
|
||||
"vue-component-type-helpers": "3.1.8",
|
||||
"vue-component-type-helpers": "3.2.1",
|
||||
"vue-eslint-parser": "10.2.0",
|
||||
"vue-tsc": "3.1.8"
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
"isolatedModules": true,
|
||||
"useDefineForClassFields": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@@/*": ["../frontend-shared/*"]
|
||||
|
||||
@@ -60,7 +60,7 @@ async function buildSrc() {
|
||||
|
||||
function buildDts() {
|
||||
return execa(
|
||||
'tsc',
|
||||
'tsgo',
|
||||
[
|
||||
'--project', 'tsconfig.json',
|
||||
'--outDir', 'js-built',
|
||||
|
||||
@@ -17,17 +17,16 @@
|
||||
"build": "node ./build.js",
|
||||
"watch": "nodemon -w package.json -e json --exec \"node ./build.js --watch\"",
|
||||
"eslint": "eslint './**/*.{js,jsx,ts,tsx}'",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"lint": "pnpm typecheck && pnpm eslint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.10.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.49.0",
|
||||
"@typescript-eslint/parser": "8.49.0",
|
||||
"esbuild": "0.27.1",
|
||||
"@types/node": "24.10.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.50.1",
|
||||
"@typescript-eslint/parser": "8.50.1",
|
||||
"esbuild": "0.27.2",
|
||||
"eslint-plugin-vue": "10.6.2",
|
||||
"nodemon": "3.1.11",
|
||||
"typescript": "5.9.3",
|
||||
"vue-eslint-parser": "10.2.0"
|
||||
},
|
||||
"files": [
|
||||
@@ -36,6 +35,6 @@
|
||||
"dependencies": {
|
||||
"i18n": "workspace:*",
|
||||
"misskey-js": "workspace:*",
|
||||
"vue": "3.5.25"
|
||||
"vue": "3.5.26"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
"noImplicitReturns": true,
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
"@@/*": ["./*"]
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"watch": "vite",
|
||||
"build": "tsx build.ts",
|
||||
"storybook-dev": "nodemon --verbose --watch src --ext \"mdx,ts,vue\" --ignore \"*.stories.ts\" --exec \"pnpm build-storybook-pre && pnpm exec storybook dev -p 6006 --ci\"",
|
||||
"build-storybook-pre": "(tsc -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js",
|
||||
"build-storybook-pre": "(tsgo -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js",
|
||||
"build-storybook": "pnpm build-storybook-pre && storybook build --webpack-stats-json storybook-static",
|
||||
"chromatic": "chromatic",
|
||||
"test": "vitest --run --globals",
|
||||
@@ -25,11 +25,11 @@
|
||||
"@rollup/plugin-json": "6.1.0",
|
||||
"@rollup/plugin-replace": "6.0.3",
|
||||
"@rollup/pluginutils": "5.3.0",
|
||||
"@sentry/vue": "10.29.0",
|
||||
"@syuilo/aiscript": "1.2.0",
|
||||
"@sentry/vue": "10.32.1",
|
||||
"@syuilo/aiscript": "1.2.1",
|
||||
"@syuilo/aiscript-0-19-0": "npm:@syuilo/aiscript@^0.19.0",
|
||||
"@twemoji/parser": "16.0.0",
|
||||
"@vitejs/plugin-vue": "6.0.2",
|
||||
"@vitejs/plugin-vue": "6.0.3",
|
||||
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.16",
|
||||
"analytics": "0.8.19",
|
||||
"broadcast-channel": "7.2.0",
|
||||
@@ -55,7 +55,7 @@
|
||||
"is-file-animated": "1.0.2",
|
||||
"json5": "2.2.3",
|
||||
"matter-js": "0.20.0",
|
||||
"mediabunny": "1.25.8",
|
||||
"mediabunny": "1.27.2",
|
||||
"mfm-js": "0.25.0",
|
||||
"misskey-bubble-game": "workspace:*",
|
||||
"misskey-js": "workspace:*",
|
||||
@@ -64,59 +64,59 @@
|
||||
"punycode.js": "2.3.1",
|
||||
"qr-code-styling": "1.9.2",
|
||||
"qr-scanner": "1.4.2",
|
||||
"rollup": "4.53.3",
|
||||
"rollup": "4.54.0",
|
||||
"sanitize-html": "2.17.0",
|
||||
"sass": "1.95.1",
|
||||
"shiki": "3.19.0",
|
||||
"sass": "1.97.1",
|
||||
"shiki": "3.20.0",
|
||||
"textarea-caret": "3.1.0",
|
||||
"three": "0.181.2",
|
||||
"three": "0.182.0",
|
||||
"throttle-debounce": "5.0.2",
|
||||
"tinycolor2": "1.6.0",
|
||||
"v-code-diff": "1.13.1",
|
||||
"vite": "7.2.7",
|
||||
"vue": "3.5.25",
|
||||
"vite": "7.3.0",
|
||||
"vue": "3.5.26",
|
||||
"vuedraggable": "next",
|
||||
"wanakana": "5.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@misskey-dev/summaly": "5.2.5",
|
||||
"@storybook/addon-essentials": "8.6.14",
|
||||
"@storybook/addon-interactions": "8.6.14",
|
||||
"@storybook/addon-links": "10.1.5",
|
||||
"@storybook/addon-mdx-gfm": "8.6.14",
|
||||
"@storybook/addon-storysource": "8.6.14",
|
||||
"@storybook/blocks": "8.6.14",
|
||||
"@storybook/components": "8.6.14",
|
||||
"@storybook/core-events": "8.6.14",
|
||||
"@storybook/manager-api": "8.6.14",
|
||||
"@storybook/preview-api": "8.6.14",
|
||||
"@storybook/react": "10.1.5",
|
||||
"@storybook/react-vite": "10.1.5",
|
||||
"@storybook/test": "8.6.14",
|
||||
"@storybook/theming": "8.6.14",
|
||||
"@storybook/types": "8.6.14",
|
||||
"@storybook/vue3": "10.1.5",
|
||||
"@storybook/vue3-vite": "10.1.5",
|
||||
"@storybook/addon-essentials": "8.6.15",
|
||||
"@storybook/addon-interactions": "8.6.15",
|
||||
"@storybook/addon-links": "10.1.10",
|
||||
"@storybook/addon-mdx-gfm": "8.6.15",
|
||||
"@storybook/addon-storysource": "8.6.15",
|
||||
"@storybook/blocks": "8.6.15",
|
||||
"@storybook/components": "8.6.15",
|
||||
"@storybook/core-events": "8.6.15",
|
||||
"@storybook/manager-api": "8.6.15",
|
||||
"@storybook/preview-api": "8.6.15",
|
||||
"@storybook/react": "10.1.10",
|
||||
"@storybook/react-vite": "10.1.10",
|
||||
"@storybook/test": "8.6.15",
|
||||
"@storybook/theming": "8.6.15",
|
||||
"@storybook/types": "8.6.15",
|
||||
"@storybook/vue3": "10.1.10",
|
||||
"@storybook/vue3-vite": "10.1.10",
|
||||
"@tabler/icons-webfont": "3.35.0",
|
||||
"@testing-library/vue": "8.1.0",
|
||||
"@types/canvas-confetti": "1.9.0",
|
||||
"@types/estree": "1.0.8",
|
||||
"@types/matter-js": "0.20.2",
|
||||
"@types/micromatch": "4.0.10",
|
||||
"@types/node": "24.10.2",
|
||||
"@types/node": "24.10.4",
|
||||
"@types/punycode.js": "npm:@types/punycode@2.1.4",
|
||||
"@types/sanitize-html": "2.16.0",
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"@types/throttle-debounce": "5.0.2",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@typescript-eslint/eslint-plugin": "8.49.0",
|
||||
"@typescript-eslint/parser": "8.49.0",
|
||||
"@vitest/coverage-v8": "4.0.15",
|
||||
"@vue/compiler-core": "3.5.25",
|
||||
"@typescript-eslint/eslint-plugin": "8.50.1",
|
||||
"@typescript-eslint/parser": "8.50.1",
|
||||
"@vitest/coverage-v8": "4.0.16",
|
||||
"@vue/compiler-core": "3.5.26",
|
||||
"acorn": "8.15.0",
|
||||
"astring": "1.9.0",
|
||||
"cross-env": "10.1.0",
|
||||
"cypress": "15.7.1",
|
||||
"cypress": "15.8.1",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-vue": "10.6.2",
|
||||
"estree-walker": "3.0.3",
|
||||
@@ -125,23 +125,22 @@
|
||||
"magic-string": "0.30.21",
|
||||
"micromatch": "4.0.8",
|
||||
"minimatch": "10.1.1",
|
||||
"msw": "2.12.4",
|
||||
"msw": "2.12.6",
|
||||
"msw-storybook-addon": "2.0.6",
|
||||
"nodemon": "3.1.11",
|
||||
"prettier": "3.7.4",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"seedrandom": "3.0.5",
|
||||
"start-server-and-test": "2.1.3",
|
||||
"storybook": "10.1.5",
|
||||
"storybook": "10.1.10",
|
||||
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"vite-plugin-glsl": "1.5.5",
|
||||
"vite-plugin-turbosnap": "1.0.3",
|
||||
"vitest": "4.0.15",
|
||||
"vitest": "4.0.16",
|
||||
"vitest-fetch-mock": "0.4.5",
|
||||
"vue-component-type-helpers": "3.1.8",
|
||||
"vue-component-type-helpers": "3.2.1",
|
||||
"vue-eslint-parser": "10.2.0",
|
||||
"vue-tsc": "3.1.8"
|
||||
}
|
||||
|
||||
@@ -69,7 +69,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
v-for="(f, i) in foldersPaginator.items.value"
|
||||
:key="f.id"
|
||||
v-anim="i"
|
||||
:class="$style.folder"
|
||||
:folder="f"
|
||||
:selectMode="select === 'folder'"
|
||||
:isSelected="selectedFolders.some(x => x.id === f.id)"
|
||||
@@ -102,7 +101,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
>
|
||||
<XFile
|
||||
v-for="file in item.items" :key="file.id"
|
||||
:class="$style.file"
|
||||
:file="file"
|
||||
:folder="folder"
|
||||
:isSelected="selectedFiles.some(x => x.id === file.id)"
|
||||
@@ -125,7 +123,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
>
|
||||
<XFile
|
||||
v-for="file in filesPaginator.items.value" :key="file.id"
|
||||
:class="$style.file"
|
||||
:file="file"
|
||||
:folder="folder"
|
||||
:isSelected="selectedFiles.some(x => x.id === file.id)"
|
||||
@@ -135,7 +132,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
/>
|
||||
</TransitionGroup>
|
||||
|
||||
<MkButton v-show="filesPaginator.canFetchOlder.value" :class="$style.loadMore" primary rounded @click="filesPaginator.fetchOlder()">{{ i18n.ts.loadMore }}</MkButton>
|
||||
<MkButton
|
||||
v-show="canFetchFiles"
|
||||
v-appear="shouldEnableInfiniteScroll ? fetchMoreFiles : null"
|
||||
:class="$style.loadMore"
|
||||
primary
|
||||
rounded
|
||||
@click="fetchMoreFiles"
|
||||
>{{ i18n.ts.loadMore }}</MkButton>
|
||||
|
||||
<div v-if="filesPaginator.items.value.length == 0 && foldersPaginator.items.value.length == 0 && !fetching" :class="$style.empty">
|
||||
<div v-if="draghover">{{ i18n.ts.dropHereToUpload }}</div>
|
||||
@@ -182,10 +186,12 @@ const props = withDefaults(defineProps<{
|
||||
type?: string;
|
||||
multiple?: boolean;
|
||||
select?: 'file' | 'folder' | null;
|
||||
forceDisableInfiniteScroll?: boolean;
|
||||
}>(), {
|
||||
initialFolder: null,
|
||||
multiple: false,
|
||||
select: null,
|
||||
forceDisableInfiniteScroll: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -194,6 +200,10 @@ const emit = defineEmits<{
|
||||
(ev: 'cd', v: Misskey.entities.DriveFolder | null): void;
|
||||
}>();
|
||||
|
||||
const shouldEnableInfiniteScroll = computed(() => {
|
||||
return prefer.r.enableInfiniteScroll.value && !props.forceDisableInfiniteScroll;
|
||||
});
|
||||
|
||||
const folder = ref<Misskey.entities.DriveFolder | null>(null);
|
||||
const hierarchyFolders = ref<Misskey.entities.DriveFolder[]>([]);
|
||||
|
||||
@@ -228,10 +238,9 @@ const filesPaginator = markRaw(new Paginator('drive/files', {
|
||||
params: () => ({ // 自動でリロードしたくないためcomputedParamsは使わない
|
||||
folderId: folder.value ? folder.value.id : null,
|
||||
type: props.type,
|
||||
sort: sortModeSelect.value,
|
||||
sort: ['-createdAt', '+createdAt'].includes(sortModeSelect.value) ? null : sortModeSelect.value,
|
||||
}),
|
||||
}));
|
||||
|
||||
const foldersPaginator = markRaw(new Paginator('drive/folders', {
|
||||
limit: 30,
|
||||
canFetchDetection: 'limit',
|
||||
@@ -240,6 +249,16 @@ const foldersPaginator = markRaw(new Paginator('drive/folders', {
|
||||
}),
|
||||
}));
|
||||
|
||||
const canFetchFiles = computed(() => !fetching.value && (filesPaginator.order.value === 'oldest' ? filesPaginator.canFetchNewer.value : filesPaginator.canFetchOlder.value));
|
||||
|
||||
async function fetchMoreFiles() {
|
||||
if (filesPaginator.order.value === 'oldest') {
|
||||
filesPaginator.fetchNewer();
|
||||
} else {
|
||||
filesPaginator.fetchOlder();
|
||||
}
|
||||
}
|
||||
|
||||
const filesTimeline = makeDateGroupedTimelineComputedRef(filesPaginator.items, 'month');
|
||||
const shouldBeGroupedByDate = computed(() => ['+createdAt', '-createdAt'].includes(sortModeSelect.value));
|
||||
|
||||
@@ -250,10 +269,10 @@ watch(sortModeSelect, () => {
|
||||
|
||||
async function initialize() {
|
||||
fetching.value = true;
|
||||
await Promise.all([
|
||||
foldersPaginator.init(),
|
||||
filesPaginator.init(),
|
||||
]);
|
||||
await foldersPaginator.reload();
|
||||
filesPaginator.initialDirection = sortModeSelect.value === '-createdAt' ? 'newer' : 'older';
|
||||
filesPaginator.order.value = sortModeSelect.value === '-createdAt' ? 'oldest' : 'newest';
|
||||
await filesPaginator.reload();
|
||||
fetching.value = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,9 +23,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:enterFromClass="$style.transition_x_enterFrom"
|
||||
:leaveToClass="$style.transition_x_leaveTo"
|
||||
>
|
||||
<div v-if="phase === 'input'" key="input" :class="$style.embedCodeGenInputRoot">
|
||||
<div :class="[$style.embedCodeGenPreviewRoot, prefer.s.animation ? $style.animatedBg : null]">
|
||||
<MkLoading v-if="iframeLoading" :class="$style.embedCodeGenPreviewSpinner"/>
|
||||
<MkPreviewWithControls v-if="phase === 'input'" key="input" :previewLoading="iframeLoading">
|
||||
<template #preview>
|
||||
<div :class="$style.embedCodeGenPreviewWrapper">
|
||||
<div class="_acrylic" :class="$style.embedCodeGenPreviewTitle">{{ i18n.ts.preview }}</div>
|
||||
<div ref="resizerRootEl" :class="$style.embedCodeGenPreviewResizerRoot" inert>
|
||||
@@ -43,27 +42,29 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.embedCodeGenSettings" class="_gaps">
|
||||
<MkInput v-if="isEmbedWithScrollbar" v-model="maxHeight" type="number" :min="0">
|
||||
<template #label>{{ i18n.ts._embedCodeGen.maxHeight }}</template>
|
||||
<template #suffix>px</template>
|
||||
<template #caption>{{ i18n.ts._embedCodeGen.maxHeightDescription }}</template>
|
||||
</MkInput>
|
||||
<MkSelect v-model="colorMode" :items="colorModeDef">
|
||||
<template #label>{{ i18n.ts.theme }}</template>
|
||||
</MkSelect>
|
||||
<MkSwitch v-if="isEmbedWithScrollbar" v-model="header">{{ i18n.ts._embedCodeGen.header }}</MkSwitch>
|
||||
<MkSwitch v-model="rounded">{{ i18n.ts._embedCodeGen.rounded }}</MkSwitch>
|
||||
<MkSwitch v-model="border">{{ i18n.ts._embedCodeGen.border }}</MkSwitch>
|
||||
<MkInfo v-if="isEmbedWithScrollbar && (!maxHeight || maxHeight <= 0)" warn>{{ i18n.ts._embedCodeGen.maxHeightWarn }}</MkInfo>
|
||||
<MkInfo v-if="typeof maxHeight === 'number' && (maxHeight <= 0 || maxHeight > 700)">{{ i18n.ts._embedCodeGen.previewIsNotActual }}</MkInfo>
|
||||
<div class="_buttons">
|
||||
<MkButton :disabled="iframeLoading" @click="applyToPreview">{{ i18n.ts._embedCodeGen.applyToPreview }}</MkButton>
|
||||
<MkButton :disabled="iframeLoading" primary @click="generate">{{ i18n.ts._embedCodeGen.generateCode }} <i class="ti ti-arrow-right"></i></MkButton>
|
||||
</template>
|
||||
<template #controls>
|
||||
<div class="_spacer _gaps">
|
||||
<MkInput v-if="isEmbedWithScrollbar" v-model="maxHeight" type="number" :min="0">
|
||||
<template #label>{{ i18n.ts._embedCodeGen.maxHeight }}</template>
|
||||
<template #suffix>px</template>
|
||||
<template #caption>{{ i18n.ts._embedCodeGen.maxHeightDescription }}</template>
|
||||
</MkInput>
|
||||
<MkSelect v-model="colorMode" :items="colorModeDef">
|
||||
<template #label>{{ i18n.ts.theme }}</template>
|
||||
</MkSelect>
|
||||
<MkSwitch v-if="isEmbedWithScrollbar" v-model="header">{{ i18n.ts._embedCodeGen.header }}</MkSwitch>
|
||||
<MkSwitch v-model="rounded">{{ i18n.ts._embedCodeGen.rounded }}</MkSwitch>
|
||||
<MkSwitch v-model="border">{{ i18n.ts._embedCodeGen.border }}</MkSwitch>
|
||||
<MkInfo v-if="isEmbedWithScrollbar && (!maxHeight || maxHeight <= 0)" warn>{{ i18n.ts._embedCodeGen.maxHeightWarn }}</MkInfo>
|
||||
<MkInfo v-if="typeof maxHeight === 'number' && (maxHeight <= 0 || maxHeight > 700)">{{ i18n.ts._embedCodeGen.previewIsNotActual }}</MkInfo>
|
||||
<div class="_buttons">
|
||||
<MkButton :disabled="iframeLoading" @click="applyToPreview">{{ i18n.ts._embedCodeGen.applyToPreview }}</MkButton>
|
||||
<MkButton :disabled="iframeLoading" primary @click="generate">{{ i18n.ts._embedCodeGen.generateCode }} <i class="ti ti-arrow-right"></i></MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</MkPreviewWithControls>
|
||||
<div v-else-if="phase === 'result'" key="result" :class="$style.embedCodeGenResultRoot">
|
||||
<div :class="$style.embedCodeGenResultWrapper" class="_gaps">
|
||||
<div class="_gaps_s">
|
||||
@@ -89,18 +90,17 @@ import { url } from '@@/js/config.js';
|
||||
import { embedRouteWithScrollbar } from '@@/js/embed-page.js';
|
||||
import type { EmbeddableEntity, EmbedParams } from '@@/js/embed-page.js';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import MkPreviewWithControls from '@/components/MkPreviewWithControls.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkCode from '@/components/MkCode.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { useMkSelect } from '@/composables/use-mkselect.js';
|
||||
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
|
||||
import { normalizeEmbedParams, getEmbedCode } from '@/utility/get-embed-code.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'ok'): void;
|
||||
@@ -302,29 +302,6 @@ onUnmounted(() => {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.embedCodeGenInputRoot {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
}
|
||||
|
||||
.embedCodeGenPreviewRoot {
|
||||
position: relative;
|
||||
cursor: not-allowed;
|
||||
background-color: var(--MI_THEME-bg);
|
||||
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.animatedBg {
|
||||
animation: bg 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes bg {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: -20px -20px; }
|
||||
}
|
||||
|
||||
.embedCodeGenPreviewWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -372,11 +349,6 @@ onUnmounted(() => {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
.embedCodeGenSettings {
|
||||
padding: 24px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.embedCodeGenResultRoot {
|
||||
box-sizing: border-box;
|
||||
padding: 24px;
|
||||
@@ -417,11 +389,4 @@ onUnmounted(() => {
|
||||
.embedCodeGenResultButtons {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@container (max-width: 800px) {
|
||||
.embedCodeGenInputRoot {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,7 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template>
|
||||
<div>
|
||||
<MkPagination v-slot="{ items }" :paginator="paginator">
|
||||
<div :class="[$style.fileList, { [$style.grid]: viewMode === 'grid', [$style.list]: viewMode === 'list', '_gaps_s': viewMode === 'list' }]">
|
||||
<div
|
||||
:class="{
|
||||
[$style.grid]: viewMode === 'grid',
|
||||
[$style.list]: viewMode === 'list',
|
||||
'_gaps_s': viewMode === 'list',
|
||||
}"
|
||||
>
|
||||
<MkA
|
||||
v-for="file in items"
|
||||
:key="file.id"
|
||||
|
||||
@@ -81,7 +81,13 @@ function onFollowChange(user: Misskey.entities.UserDetailed) {
|
||||
}
|
||||
|
||||
async function onClick() {
|
||||
pleaseLogin({ openOnRemote: { type: 'web', path: `/@${props.user.username}@${props.user.host ?? host}` } });
|
||||
const isLoggedIn = await pleaseLogin({
|
||||
openOnRemote: {
|
||||
type: 'web',
|
||||
path: `/@${props.user.username}@${props.user.host ?? host}`,
|
||||
},
|
||||
});
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
wait.value = true;
|
||||
|
||||
|
||||
84
packages/frontend/src/components/MkForm.vue
Normal file
84
packages/frontend/src/components/MkForm.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div v-if="Object.keys(form).filter(item => !form[item].hidden).length > 0" class="_gaps_m">
|
||||
<template v-for="v, k in form">
|
||||
<template v-if="typeof v.hidden == 'function' ? v.hidden(values) : v.hidden"></template>
|
||||
<MkInput v-else-if="v.type === 'number'" v-model="values[k]" type="number" :step="v.step || 1" :manualSave="v.manualSave">
|
||||
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<template v-if="v.description" #caption>{{ v.description }}</template>
|
||||
</MkInput>
|
||||
<MkInput v-else-if="v.type === 'string' && !v.multiline" v-model="values[k]" type="text" :mfmAutocomplete="v.treatAsMfm" :manualSave="v.manualSave">
|
||||
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<template v-if="v.description" #caption>{{ v.description }}</template>
|
||||
</MkInput>
|
||||
<MkTextarea v-else-if="v.type === 'string' && v.multiline" v-model="values[k]" :mfmAutocomplete="v.treatAsMfm" :mfmPreview="v.treatAsMfm" :manualSave="v.manualSave">
|
||||
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<template v-if="v.description" #caption>{{ v.description }}</template>
|
||||
</MkTextarea>
|
||||
<MkSwitch v-else-if="v.type === 'boolean'" v-model="values[k]">
|
||||
<span v-text="v.label || k"></span>
|
||||
<template v-if="v.description" #caption>{{ v.description }}</template>
|
||||
</MkSwitch>
|
||||
<MkSelect v-else-if="v.type === 'enum'" v-model="values[k]" :items="getMkSelectDef(v)">
|
||||
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
</MkSelect>
|
||||
<MkRadios v-else-if="v.type === 'radio'" v-model="values[k]">
|
||||
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<option v-for="option in v.options" :key="getRadioKey(option)" :value="option.value">{{ option.label }}</option>
|
||||
</MkRadios>
|
||||
<MkRange v-else-if="v.type === 'range'" v-model="values[k]" :min="v.min" :max="v.max" :step="v.step" :textConverter="v.textConverter">
|
||||
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<template v-if="v.description" #caption>{{ v.description }}</template>
|
||||
</MkRange>
|
||||
<MkButton v-else-if="v.type === 'button'" @click="v.action($event, values)">
|
||||
<span v-text="v.content || k"></span>
|
||||
</MkButton>
|
||||
<XFile
|
||||
v-else-if="v.type === 'drive-file'"
|
||||
:fileId="v.defaultFileId"
|
||||
:validate="async f => !v.validate || await v.validate(f)"
|
||||
@update="f => values[k] = f"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<MkResult v-else type="empty" :text="i18n.ts.nothingToConfigure"/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import XFile from '@/components/MkForm.file.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkTextarea from '@/components/MkTextarea.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkRange from '@/components/MkRange.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkRadios from '@/components/MkRadios.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import type { MkSelectItem } from '@/components/MkSelect.vue';
|
||||
import type { Form, EnumFormItem, RadioFormItem } from '@/utility/form.js';
|
||||
|
||||
const props = defineProps<{
|
||||
form: Form;
|
||||
}>();
|
||||
|
||||
// TODO: ジェネリックにしたい
|
||||
const values = defineModel<Record<string, any>>({ required: true });
|
||||
|
||||
function getMkSelectDef(def: EnumFormItem): MkSelectItem[] {
|
||||
return def.enum.map((v) => {
|
||||
if (typeof v === 'string') {
|
||||
return { value: v, label: v };
|
||||
} else {
|
||||
return { value: v.value, label: v.label };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getRadioKey(e: RadioFormItem['options'][number]) {
|
||||
return typeof e.value === 'string' ? e.value : JSON.stringify(e.value);
|
||||
}
|
||||
</script>
|
||||
@@ -20,66 +20,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 32px;">
|
||||
<div v-if="Object.keys(form).filter(item => !form[item].hidden).length > 0" class="_gaps_m">
|
||||
<template v-for="(v, k) in Object.fromEntries(Object.entries(form))">
|
||||
<template v-if="typeof v.hidden == 'function' ? v.hidden(values) : v.hidden"></template>
|
||||
<MkInput v-else-if="v.type === 'number'" v-model="values[k]" type="number" :step="v.step || 1">
|
||||
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<template v-if="v.description" #caption>{{ v.description }}</template>
|
||||
</MkInput>
|
||||
<MkInput v-else-if="v.type === 'string' && !v.multiline" v-model="values[k]" type="text" :mfmAutocomplete="v.treatAsMfm">
|
||||
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<template v-if="v.description" #caption>{{ v.description }}</template>
|
||||
</MkInput>
|
||||
<MkTextarea v-else-if="v.type === 'string' && v.multiline" v-model="values[k]" :mfmAutocomplete="v.treatAsMfm" :mfmPreview="v.treatAsMfm">
|
||||
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<template v-if="v.description" #caption>{{ v.description }}</template>
|
||||
</MkTextarea>
|
||||
<MkSwitch v-else-if="v.type === 'boolean'" v-model="values[k]">
|
||||
<span v-text="v.label || k"></span>
|
||||
<template v-if="v.description" #caption>{{ v.description }}</template>
|
||||
</MkSwitch>
|
||||
<MkSelect v-else-if="v.type === 'enum'" v-model="values[k]" :items="getMkSelectDef(v)">
|
||||
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
</MkSelect>
|
||||
<MkRadios v-else-if="v.type === 'radio'" v-model="values[k]">
|
||||
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<option v-for="option in v.options" :key="getRadioKey(option)" :value="option.value">{{ option.label }}</option>
|
||||
</MkRadios>
|
||||
<MkRange v-else-if="v.type === 'range'" v-model="values[k]" :min="v.min" :max="v.max" :step="v.step" :textConverter="v.textConverter">
|
||||
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<template v-if="v.description" #caption>{{ v.description }}</template>
|
||||
</MkRange>
|
||||
<MkButton v-else-if="v.type === 'button'" @click="v.action($event, values)">
|
||||
<span v-text="v.content || k"></span>
|
||||
</MkButton>
|
||||
<XFile
|
||||
v-else-if="v.type === 'drive-file'"
|
||||
:fileId="v.defaultFileId"
|
||||
:validate="async f => !v.validate || await v.validate(f)"
|
||||
@update="f => values[k] = f"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<MkResult v-else type="empty"/>
|
||||
<MkForm v-model="values" :form="form"/>
|
||||
</div>
|
||||
</MkModalWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, useTemplateRef } from 'vue';
|
||||
import MkInput from './MkInput.vue';
|
||||
import MkTextarea from './MkTextarea.vue';
|
||||
import MkSwitch from './MkSwitch.vue';
|
||||
import MkSelect from './MkSelect.vue';
|
||||
import MkRange from './MkRange.vue';
|
||||
import MkButton from './MkButton.vue';
|
||||
import MkRadios from './MkRadios.vue';
|
||||
import XFile from './MkFormDialog.file.vue';
|
||||
import type { MkSelectItem } from '@/components/MkSelect.vue';
|
||||
import type { Form, EnumFormItem, RadioFormItem } from '@/utility/form.js';
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import type { Form } from '@/utility/form.js';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkForm from '@/components/MkForm.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
@@ -96,19 +46,22 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const values = reactive({});
|
||||
|
||||
for (const item in props.form) {
|
||||
if ('default' in props.form[item]) {
|
||||
values[item] = props.form[item].default ?? null;
|
||||
} else {
|
||||
values[item] = null;
|
||||
const values = ref((() => {
|
||||
const obj: Record<string, any> = {};
|
||||
for (const item in props.form) {
|
||||
if ('default' in props.form[item]) {
|
||||
obj[item] = props.form[item].default ?? null;
|
||||
} else {
|
||||
obj[item] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
})());
|
||||
|
||||
function ok() {
|
||||
emit('done', {
|
||||
result: values,
|
||||
result: values.value,
|
||||
});
|
||||
dialog.value?.close();
|
||||
}
|
||||
@@ -119,18 +72,4 @@ function cancel() {
|
||||
});
|
||||
dialog.value?.close();
|
||||
}
|
||||
|
||||
function getMkSelectDef(def: EnumFormItem): MkSelectItem[] {
|
||||
return def.enum.map((v) => {
|
||||
if (typeof v === 'string') {
|
||||
return { value: v, label: v };
|
||||
} else {
|
||||
return { value: v.value, label: v.label };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getRadioKey(e: RadioFormItem['options'][number]) {
|
||||
return typeof e.value === 'string' ? e.value : JSON.stringify(e.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -16,37 +16,36 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
>
|
||||
<template #header><i class="ti ti-sparkles"></i> {{ i18n.ts._imageEffector.title }}</template>
|
||||
|
||||
<div :class="$style.root">
|
||||
<div :class="$style.container">
|
||||
<div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]">
|
||||
<canvas ref="canvasEl" :class="$style.previewCanvas" @pointerdown.prevent.stop="onImagePointerdown"></canvas>
|
||||
<div :class="$style.previewContainer">
|
||||
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
|
||||
<div class="_acrylic" :class="$style.editControls">
|
||||
<button class="_button" :class="[$style.previewControlsButton, penMode != null ? $style.active : null]" @click="showPenMenu"><i class="ti ti-pencil"></i></button>
|
||||
</div>
|
||||
<div class="_acrylic" :class="$style.previewControls">
|
||||
<button class="_button" :class="[$style.previewControlsButton, !enabled ? $style.active : null]" @click="enabled = false">Before</button>
|
||||
<button class="_button" :class="[$style.previewControlsButton, enabled ? $style.active : null]" @click="enabled = true">After</button>
|
||||
</div>
|
||||
<MkPreviewWithControls>
|
||||
<template #preview>
|
||||
<canvas ref="canvasEl" :class="$style.previewCanvas" @pointerdown.prevent.stop="onImagePointerdown"></canvas>
|
||||
<div :class="$style.previewContainer">
|
||||
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
|
||||
<div class="_acrylic" :class="$style.editControls">
|
||||
<button class="_button" :class="[$style.previewControlsButton, penMode != null ? $style.active : null]" @click="showPenMenu"><i class="ti ti-pencil"></i></button>
|
||||
</div>
|
||||
<div class="_acrylic" :class="$style.previewControls">
|
||||
<button class="_button" :class="[$style.previewControlsButton, !enabled ? $style.active : null]" @click="enabled = false">Before</button>
|
||||
<button class="_button" :class="[$style.previewControlsButton, enabled ? $style.active : null]" @click="enabled = true">After</button>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.controls">
|
||||
<div class="_spacer _gaps">
|
||||
<XLayer
|
||||
v-for="(layer, i) in layers"
|
||||
:key="layer.id"
|
||||
v-model:layer="layers[i]"
|
||||
@del="onLayerDelete(layer)"
|
||||
@swapUp="onLayerSwapUp(layer)"
|
||||
@swapDown="onLayerSwapDown(layer)"
|
||||
></XLayer>
|
||||
</template>
|
||||
|
||||
<MkButton rounded primary style="margin: 0 auto;" @click="addEffect"><i class="ti ti-plus"></i> {{ i18n.ts._imageEffector.addEffect }}</MkButton>
|
||||
</div>
|
||||
<template #controls>
|
||||
<div class="_spacer _gaps">
|
||||
<XLayer
|
||||
v-for="(layer, i) in layers"
|
||||
:key="layer.id"
|
||||
v-model:layer="layers[i]"
|
||||
@del="onLayerDelete(layer)"
|
||||
@swapUp="onLayerSwapUp(layer)"
|
||||
@swapDown="onLayerSwapDown(layer)"
|
||||
></XLayer>
|
||||
|
||||
<MkButton rounded primary style="margin: 0 auto;" @click="addEffect"><i class="ti ti-plus"></i> {{ i18n.ts._imageEffector.addEffect }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</MkPreviewWithControls>
|
||||
</MkModalWindow>
|
||||
</template>
|
||||
|
||||
@@ -56,15 +55,12 @@ import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkPreviewWithControls from '@/components/MkPreviewWithControls.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import XLayer from '@/components/MkImageEffectorDialog.Layer.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { deepClone } from '@/utility/clone.js';
|
||||
import { FXS } from '@/utility/image-effector/fxs.js';
|
||||
import { genId } from '@/utility/id.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const props = defineProps<{
|
||||
image: File;
|
||||
@@ -367,33 +363,6 @@ function onImagePointerdown(ev: PointerEvent) {
|
||||
</script>
|
||||
|
||||
<style module>
|
||||
.root {
|
||||
container-type: inline-size;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
position: relative;
|
||||
background-color: var(--MI_THEME-bg);
|
||||
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.animatedBg {
|
||||
animation: bg 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes bg {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: -20px -20px; }
|
||||
}
|
||||
|
||||
.previewContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -442,16 +411,6 @@ function onImagePointerdown(ev: PointerEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
.previewSpinner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.previewCanvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -467,15 +426,4 @@ function onImagePointerdown(ev: PointerEvent) {
|
||||
object-fit: contain;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.controls {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
@container (max-width: 800px) {
|
||||
.container {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -48,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</MkInput>
|
||||
</div>
|
||||
<div v-if="Object.keys(paramDefs).length === 0" :class="$style.nothingToConfigure">
|
||||
{{ i18n.ts._imageEffector.nothingToConfigure }}
|
||||
{{ i18n.ts.nothingToConfigure }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -16,140 +16,139 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
>
|
||||
<template #header><i class="ti ti-device-ipad-horizontal"></i> {{ i18n.ts._imageFrameEditor.title }}</template>
|
||||
|
||||
<div :class="$style.root">
|
||||
<div :class="$style.container">
|
||||
<div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]">
|
||||
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
|
||||
<div :class="$style.previewContainer">
|
||||
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
|
||||
<div v-if="props.image == null" class="_acrylic" :class="$style.previewControls">
|
||||
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button>
|
||||
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button>
|
||||
<button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button>
|
||||
<MkPreviewWithControls>
|
||||
<template #preview>
|
||||
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
|
||||
<div :class="$style.previewContainer">
|
||||
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
|
||||
<div v-if="props.image == null" class="_acrylic" :class="$style.previewControls">
|
||||
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button>
|
||||
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button>
|
||||
<button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #controls>
|
||||
<div class="_spacer _gaps">
|
||||
<MkRange v-model="params.borderThickness" :min="0" :max="0.2" :step="0.01" :continuousUpdate="true">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.borderThickness }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkInput :modelValue="getHex(params.bgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.bgColor = c; }">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.backgroundColor }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkInput :modelValue="getHex(params.fgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.fgColor = c; }">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.textColor }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkSelect
|
||||
v-model="params.font" :items="[
|
||||
{ label: i18n.ts._imageFrameEditor.fontSansSerif, value: 'sans-serif' },
|
||||
{ label: i18n.ts._imageFrameEditor.fontSerif, value: 'serif' },
|
||||
]"
|
||||
>
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.font }}</template>
|
||||
</MkSelect>
|
||||
|
||||
<MkFolder :defaultOpen="params.labelTop.enabled">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.header }}</template>
|
||||
|
||||
<div class="_gaps">
|
||||
<MkSwitch v-model="params.labelTop.enabled">
|
||||
<template #label>{{ i18n.ts.show }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkRange v-model="params.labelTop.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkRange v-model="params.labelTop.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkSwitch v-model="params.labelTop.centered">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.centered }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkInput v-model="params.labelTop.textBig">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkTextarea v-model="params.labelTop.textSmall">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template>
|
||||
</MkTextarea>
|
||||
|
||||
<MkSwitch v-model="params.labelTop.withQrCode">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template>
|
||||
</MkSwitch>
|
||||
</div>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder :defaultOpen="params.labelBottom.enabled">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.footer }}</template>
|
||||
|
||||
<div class="_gaps">
|
||||
<MkSwitch v-model="params.labelBottom.enabled">
|
||||
<template #label>{{ i18n.ts.show }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkRange v-model="params.labelBottom.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkRange v-model="params.labelBottom.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkSwitch v-model="params.labelBottom.centered">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.centered }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkInput v-model="params.labelBottom.textBig">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkTextarea v-model="params.labelBottom.textSmall">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template>
|
||||
</MkTextarea>
|
||||
|
||||
<MkSwitch v-model="params.labelBottom.withQrCode">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template>
|
||||
</MkSwitch>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkInfo>
|
||||
<div>{{ i18n.ts._imageFrameEditor.availableVariables }}:</div>
|
||||
<div><code class="_selectableAtomic">{filename}</code> - {{ i18n.ts._imageEditing._vars.filename }}</div>
|
||||
<div><code class="_selectableAtomic">{filename_without_ext}</code> - {{ i18n.ts._imageEditing._vars.filename_without_ext }}</div>
|
||||
<div><code class="_selectableAtomic">{caption}</code> - {{ i18n.ts._imageEditing._vars.caption }}</div>
|
||||
<div><code class="_selectableAtomic">{year}</code> - {{ i18n.ts._imageEditing._vars.year }}</div>
|
||||
<div><code class="_selectableAtomic">{month}</code> - {{ i18n.ts._imageEditing._vars.month }}</div>
|
||||
<div><code class="_selectableAtomic">{day}</code> - {{ i18n.ts._imageEditing._vars.day }}</div>
|
||||
<div><code class="_selectableAtomic">{hour}</code> - {{ i18n.ts._imageEditing._vars.hour }}</div>
|
||||
<div><code class="_selectableAtomic">{minute}</code> - {{ i18n.ts._imageEditing._vars.minute }}</div>
|
||||
<div><code class="_selectableAtomic">{second}</code> - {{ i18n.ts._imageEditing._vars.second }}</div>
|
||||
<div><code class="_selectableAtomic">{0month}</code> - {{ i18n.ts._imageEditing._vars.month }} ({{ i18n.ts.zeroPadding }})</div>
|
||||
<div><code class="_selectableAtomic">{0day}</code> - {{ i18n.ts._imageEditing._vars.day }} ({{ i18n.ts.zeroPadding }})</div>
|
||||
<div><code class="_selectableAtomic">{0hour}</code> - {{ i18n.ts._imageEditing._vars.hour }} ({{ i18n.ts.zeroPadding }})</div>
|
||||
<div><code class="_selectableAtomic">{0minute}</code> - {{ i18n.ts._imageEditing._vars.minute }} ({{ i18n.ts.zeroPadding }})</div>
|
||||
<div><code class="_selectableAtomic">{0second}</code> - {{ i18n.ts._imageEditing._vars.second }} ({{ i18n.ts.zeroPadding }})</div>
|
||||
<div><code class="_selectableAtomic">{camera_model}</code> - {{ i18n.ts._imageEditing._vars.camera_model }}</div>
|
||||
<div><code class="_selectableAtomic">{camera_lens_model}</code> - {{ i18n.ts._imageEditing._vars.camera_lens_model }}</div>
|
||||
<div><code class="_selectableAtomic">{camera_mm}</code> - {{ i18n.ts._imageEditing._vars.camera_mm }}</div>
|
||||
<div><code class="_selectableAtomic">{camera_mm_35}</code> - {{ i18n.ts._imageEditing._vars.camera_mm_35 }}</div>
|
||||
<div><code class="_selectableAtomic">{camera_f}</code> - {{ i18n.ts._imageEditing._vars.camera_f }}</div>
|
||||
<div><code class="_selectableAtomic">{camera_s}</code> - {{ i18n.ts._imageEditing._vars.camera_s }}</div>
|
||||
<div><code class="_selectableAtomic">{camera_iso}</code> - {{ i18n.ts._imageEditing._vars.camera_iso }}</div>
|
||||
<div><code class="_selectableAtomic">{gps_lat}</code> - {{ i18n.ts._imageEditing._vars.gps_lat }}</div>
|
||||
<div><code class="_selectableAtomic">{gps_long}</code> - {{ i18n.ts._imageEditing._vars.gps_long }}</div>
|
||||
</MkInfo>
|
||||
</div>
|
||||
<div :class="$style.controls">
|
||||
<div class="_spacer _gaps">
|
||||
<MkRange v-model="params.borderThickness" :min="0" :max="0.2" :step="0.01" :continuousUpdate="true">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.borderThickness }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkInput :modelValue="getHex(params.bgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.bgColor = c; }">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.backgroundColor }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkInput :modelValue="getHex(params.fgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.fgColor = c; }">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.textColor }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkSelect
|
||||
v-model="params.font" :items="[
|
||||
{ label: i18n.ts._imageFrameEditor.fontSansSerif, value: 'sans-serif' },
|
||||
{ label: i18n.ts._imageFrameEditor.fontSerif, value: 'serif' },
|
||||
]"
|
||||
>
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.font }}</template>
|
||||
</MkSelect>
|
||||
|
||||
<MkFolder :defaultOpen="params.labelTop.enabled">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.header }}</template>
|
||||
|
||||
<div class="_gaps">
|
||||
<MkSwitch v-model="params.labelTop.enabled">
|
||||
<template #label>{{ i18n.ts.show }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkRange v-model="params.labelTop.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkRange v-model="params.labelTop.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkSwitch v-model="params.labelTop.centered">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.centered }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkInput v-model="params.labelTop.textBig">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkTextarea v-model="params.labelTop.textSmall">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template>
|
||||
</MkTextarea>
|
||||
|
||||
<MkSwitch v-model="params.labelTop.withQrCode">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template>
|
||||
</MkSwitch>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder :defaultOpen="params.labelBottom.enabled">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.footer }}</template>
|
||||
|
||||
<div class="_gaps">
|
||||
<MkSwitch v-model="params.labelBottom.enabled">
|
||||
<template #label>{{ i18n.ts.show }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkRange v-model="params.labelBottom.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkRange v-model="params.labelBottom.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkSwitch v-model="params.labelBottom.centered">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.centered }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkInput v-model="params.labelBottom.textBig">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkTextarea v-model="params.labelBottom.textSmall">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template>
|
||||
</MkTextarea>
|
||||
|
||||
<MkSwitch v-model="params.labelBottom.withQrCode">
|
||||
<template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template>
|
||||
</MkSwitch>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkInfo>
|
||||
<div>{{ i18n.ts._imageFrameEditor.availableVariables }}:</div>
|
||||
<div><code class="_selectableAtomic">{filename}</code> - {{ i18n.ts._imageEditing._vars.filename }}</div>
|
||||
<div><code class="_selectableAtomic">{filename_without_ext}</code> - {{ i18n.ts._imageEditing._vars.filename_without_ext }}</div>
|
||||
<div><code class="_selectableAtomic">{caption}</code> - {{ i18n.ts._imageEditing._vars.caption }}</div>
|
||||
<div><code class="_selectableAtomic">{year}</code> - {{ i18n.ts._imageEditing._vars.year }}</div>
|
||||
<div><code class="_selectableAtomic">{month}</code> - {{ i18n.ts._imageEditing._vars.month }}</div>
|
||||
<div><code class="_selectableAtomic">{day}</code> - {{ i18n.ts._imageEditing._vars.day }}</div>
|
||||
<div><code class="_selectableAtomic">{hour}</code> - {{ i18n.ts._imageEditing._vars.hour }}</div>
|
||||
<div><code class="_selectableAtomic">{minute}</code> - {{ i18n.ts._imageEditing._vars.minute }}</div>
|
||||
<div><code class="_selectableAtomic">{second}</code> - {{ i18n.ts._imageEditing._vars.second }}</div>
|
||||
<div><code class="_selectableAtomic">{0month}</code> - {{ i18n.ts._imageEditing._vars.month }} ({{ i18n.ts.zeroPadding }})</div>
|
||||
<div><code class="_selectableAtomic">{0day}</code> - {{ i18n.ts._imageEditing._vars.day }} ({{ i18n.ts.zeroPadding }})</div>
|
||||
<div><code class="_selectableAtomic">{0hour}</code> - {{ i18n.ts._imageEditing._vars.hour }} ({{ i18n.ts.zeroPadding }})</div>
|
||||
<div><code class="_selectableAtomic">{0minute}</code> - {{ i18n.ts._imageEditing._vars.minute }} ({{ i18n.ts.zeroPadding }})</div>
|
||||
<div><code class="_selectableAtomic">{0second}</code> - {{ i18n.ts._imageEditing._vars.second }} ({{ i18n.ts.zeroPadding }})</div>
|
||||
<div><code class="_selectableAtomic">{camera_model}</code> - {{ i18n.ts._imageEditing._vars.camera_model }}</div>
|
||||
<div><code class="_selectableAtomic">{camera_lens_model}</code> - {{ i18n.ts._imageEditing._vars.camera_lens_model }}</div>
|
||||
<div><code class="_selectableAtomic">{camera_mm}</code> - {{ i18n.ts._imageEditing._vars.camera_mm }}</div>
|
||||
<div><code class="_selectableAtomic">{camera_mm_35}</code> - {{ i18n.ts._imageEditing._vars.camera_mm_35 }}</div>
|
||||
<div><code class="_selectableAtomic">{camera_f}</code> - {{ i18n.ts._imageEditing._vars.camera_f }}</div>
|
||||
<div><code class="_selectableAtomic">{camera_s}</code> - {{ i18n.ts._imageEditing._vars.camera_s }}</div>
|
||||
<div><code class="_selectableAtomic">{camera_iso}</code> - {{ i18n.ts._imageEditing._vars.camera_iso }}</div>
|
||||
<div><code class="_selectableAtomic">{gps_lat}</code> - {{ i18n.ts._imageEditing._vars.gps_lat }}</div>
|
||||
<div><code class="_selectableAtomic">{gps_long}</code> - {{ i18n.ts._imageEditing._vars.gps_long }}</div>
|
||||
</MkInfo>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</MkPreviewWithControls>
|
||||
</MkModalWindow>
|
||||
</template>
|
||||
|
||||
@@ -161,8 +160,8 @@ import type { ImageFrameParams, ImageFramePreset } from '@/utility/image-frame-r
|
||||
import { ImageFrameRenderer } from '@/utility/image-frame-renderer/ImageFrameRenderer.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import MkPreviewWithControls from './MkPreviewWithControls.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkRange from '@/components/MkRange.vue';
|
||||
@@ -173,8 +172,6 @@ import * as os from '@/os.js';
|
||||
import { deepClone } from '@/utility/clone.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
import { genId } from '@/utility/id.js';
|
||||
import { useMkSelect } from '@/composables/use-mkselect.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const $i = ensureSignin();
|
||||
|
||||
@@ -412,33 +409,6 @@ function getRgb(hex: string | number): [number, number, number] | null {
|
||||
</script>
|
||||
|
||||
<style module>
|
||||
.root {
|
||||
container-type: inline-size;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
position: relative;
|
||||
background-color: var(--MI_THEME-bg);
|
||||
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.animatedBg {
|
||||
animation: bg 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes bg {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: -20px -20px; }
|
||||
}
|
||||
|
||||
.previewContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -495,15 +465,4 @@ function getRgb(hex: string | number): [number, number, number] | null {
|
||||
box-sizing: border-box;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.controls {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
@container (max-width: 800px) {
|
||||
.container {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -100,6 +100,7 @@ import { hms } from '@/filters/hms.js';
|
||||
import MkMediaRange from '@/components/MkMediaRange.vue';
|
||||
import { $i, iAmModerator } from '@/i.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { canRevealFile, shouldHideFileByDefault } from '@/utility/sensitive-file.js';
|
||||
|
||||
const props = defineProps<{
|
||||
audio: Misskey.entities.DriveFile;
|
||||
@@ -154,16 +155,11 @@ function hasFocus() {
|
||||
const playerEl = useTemplateRef('playerEl');
|
||||
const audioEl = useTemplateRef('audioEl');
|
||||
|
||||
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
|
||||
const hide = ref((prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.audio.isSensitive && prefer.s.nsfw !== 'ignore'));
|
||||
const hide = ref(shouldHideFileByDefault(props.audio));
|
||||
|
||||
async function reveal() {
|
||||
if (props.audio.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.ts.sensitiveMediaRevealConfirm,
|
||||
});
|
||||
if (canceled) return;
|
||||
if (!(await canRevealFile(props.audio))) {
|
||||
return;
|
||||
}
|
||||
|
||||
hide.value = false;
|
||||
|
||||
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<MkMediaAudio v-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" :audio="media"/>
|
||||
<div v-else-if="media.isSensitive && hide" :class="$style.sensitive" @click="reveal">
|
||||
<div v-else-if="hide" :class="$style.sensitive" @click="reveal">
|
||||
<span style="font-size: 1.6em;"><i class="ti ti-alert-triangle"></i></span>
|
||||
<b>{{ i18n.ts.sensitive }}</b>
|
||||
<span>{{ i18n.ts.clickToShow }}</span>
|
||||
@@ -27,23 +27,18 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
import { ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.js';
|
||||
import MkMediaAudio from '@/components/MkMediaAudio.vue';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js';
|
||||
|
||||
const props = defineProps<{
|
||||
media: Misskey.entities.DriveFile;
|
||||
}>();
|
||||
|
||||
const hide = ref(true);
|
||||
const hide = ref(shouldHideFileByDefault(props.media));
|
||||
|
||||
async function reveal() {
|
||||
if (props.media.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.ts.sensitiveMediaRevealConfirm,
|
||||
});
|
||||
if (canceled) return;
|
||||
if (!(await canRevealFile(props.media))) {
|
||||
return;
|
||||
}
|
||||
|
||||
hide.value = false;
|
||||
|
||||
@@ -77,6 +77,7 @@ import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.js';
|
||||
import { $i, iAmModerator } from '@/i.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
image: Misskey.entities.DriveFile;
|
||||
@@ -106,12 +107,8 @@ async function reveal(ev: MouseEvent) {
|
||||
|
||||
if (hide.value) {
|
||||
ev.stopPropagation();
|
||||
if (props.image.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.ts.sensitiveMediaRevealConfirm,
|
||||
});
|
||||
if (canceled) return;
|
||||
if (!(await canRevealFile(props.image))) {
|
||||
return;
|
||||
}
|
||||
|
||||
hide.value = false;
|
||||
@@ -119,8 +116,8 @@ async function reveal(ev: MouseEvent) {
|
||||
}
|
||||
|
||||
// Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする
|
||||
watch(() => props.image, () => {
|
||||
hide.value = (prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.image.isSensitive && prefer.s.nsfw !== 'ignore');
|
||||
watch(() => props.image, (newImage) => {
|
||||
hide.value = shouldHideFileByDefault(newImage);
|
||||
}, {
|
||||
deep: true,
|
||||
immediate: true,
|
||||
|
||||
@@ -124,6 +124,7 @@ import hasAudio from '@/utility/media-has-audio.js';
|
||||
import MkMediaRange from '@/components/MkMediaRange.vue';
|
||||
import { $i, iAmModerator } from '@/i.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js';
|
||||
|
||||
const props = defineProps<{
|
||||
video: Misskey.entities.DriveFile;
|
||||
@@ -176,15 +177,11 @@ function hasFocus() {
|
||||
}
|
||||
|
||||
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
|
||||
const hide = ref((prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) ? true : (props.video.isSensitive && prefer.s.nsfw !== 'ignore'));
|
||||
const hide = ref(shouldHideFileByDefault(props.video));
|
||||
|
||||
async function reveal() {
|
||||
if (props.video.isSensitive && prefer.s.confirmWhenRevealingSensitiveMedia) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.ts.sensitiveMediaRevealConfirm,
|
||||
});
|
||||
if (canceled) return;
|
||||
if (!(await canRevealFile(props.video))) {
|
||||
return;
|
||||
}
|
||||
|
||||
hide.value = false;
|
||||
|
||||
@@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<span><MkEllipsis/></span>
|
||||
</span>
|
||||
|
||||
<div v-else-if="item.type === 'component'" role="menuitem" tabindex="-1" :class="[$style.componentItem]">
|
||||
<div v-else-if="item.type === 'component'" role="menuitem" tabindex="-1">
|
||||
<component :is="item.component" v-bind="item.props"/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -468,8 +468,12 @@ if (!props.mock) {
|
||||
}
|
||||
}
|
||||
|
||||
function renote() {
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
async function renote() {
|
||||
if (props.mock) return;
|
||||
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
showMovedDialog();
|
||||
|
||||
const { menu } = getRenoteMenu({ note: note, renoteButton, mock: props.mock });
|
||||
@@ -478,11 +482,12 @@ function renote() {
|
||||
subscribeManuallyToNoteCapture();
|
||||
}
|
||||
|
||||
function reply(): void {
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
async function reply() {
|
||||
if (props.mock) return;
|
||||
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
os.post({
|
||||
reply: appearNote,
|
||||
channel: appearNote.channel,
|
||||
@@ -491,8 +496,10 @@ function reply(): void {
|
||||
});
|
||||
}
|
||||
|
||||
function react(): void {
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
async function react() {
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
showMovedDialog();
|
||||
if (appearNote.reactionAcceptance === 'likeOnly') {
|
||||
sound.playMisskeySfx('reaction');
|
||||
@@ -621,10 +628,12 @@ async function clip(): Promise<void> {
|
||||
os.popupMenu(await getNoteClipMenu({ note: note, currentClip: currentClip?.value }), clipButton.value).then(focus);
|
||||
}
|
||||
|
||||
function showRenoteMenu(): void {
|
||||
async function showRenoteMenu() {
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
function getUnrenote(): MenuItem {
|
||||
return {
|
||||
@@ -649,7 +658,6 @@ function showRenoteMenu(): void {
|
||||
};
|
||||
|
||||
if (isMyRenote) {
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
os.popupMenu([
|
||||
renoteDetailsMenu,
|
||||
getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote),
|
||||
|
||||
@@ -448,8 +448,10 @@ if (appearNote.reactionAcceptance === 'likeOnly') {
|
||||
});
|
||||
}
|
||||
|
||||
function renote() {
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
async function renote() {
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
showMovedDialog();
|
||||
|
||||
const { menu } = getRenoteMenu({ note: note, renoteButton });
|
||||
@@ -459,8 +461,10 @@ function renote() {
|
||||
subscribeManuallyToNoteCapture();
|
||||
}
|
||||
|
||||
function reply(): void {
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
async function reply() {
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
showMovedDialog();
|
||||
os.post({
|
||||
reply: appearNote,
|
||||
@@ -470,8 +474,10 @@ function reply(): void {
|
||||
});
|
||||
}
|
||||
|
||||
function react(): void {
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
async function react() {
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
showMovedDialog();
|
||||
if (appearNote.reactionAcceptance === 'likeOnly') {
|
||||
sound.playMisskeySfx('reaction');
|
||||
@@ -569,9 +575,12 @@ async function clip(): Promise<void> {
|
||||
os.popupMenu(await getNoteClipMenu({ note: note }), clipButton.value).then(focus);
|
||||
}
|
||||
|
||||
function showRenoteMenu(): void {
|
||||
async function showRenoteMenu() {
|
||||
if (!isMyRenote) return;
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
os.popupMenu([{
|
||||
text: i18n.ts.unrenote,
|
||||
icon: 'ti ti-trash',
|
||||
|
||||
@@ -118,7 +118,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<div :class="$style.draftActions" class="_buttons">
|
||||
<template v-if="draft.scheduledAt != null && draft.isActuallyScheduled">
|
||||
<MkButton
|
||||
:class="$style.itemButton"
|
||||
small
|
||||
@click="cancelSchedule(draft)"
|
||||
>
|
||||
@@ -126,7 +125,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</MkButton>
|
||||
<!-- TODO
|
||||
<MkButton
|
||||
:class="$style.itemButton"
|
||||
small
|
||||
@click="reSchedule(draft)"
|
||||
>
|
||||
@@ -136,7 +134,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
<MkButton
|
||||
v-else
|
||||
:class="$style.itemButton"
|
||||
small
|
||||
@click="restoreDraft(draft)"
|
||||
>
|
||||
@@ -147,7 +144,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
danger
|
||||
small
|
||||
:iconOnly="true"
|
||||
:class="$style.itemButton"
|
||||
style="margin-left: auto;"
|
||||
@click="deleteDraft(draft)"
|
||||
>
|
||||
|
||||
@@ -6,14 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template>
|
||||
<template v-for="file in note.files">
|
||||
<div
|
||||
v-if="(((
|
||||
(prefer.s.nsfw === 'force' || file.isSensitive) &&
|
||||
prefer.s.nsfw !== 'ignore'
|
||||
) || (prefer.s.dataSaver.media && file.type.startsWith('image/'))) &&
|
||||
!showingFiles.has(file.id)
|
||||
)"
|
||||
v-if="isHiding(file)"
|
||||
:class="[$style.filePreview, { [$style.square]: square }]"
|
||||
@click="showingFiles.add(file.id)"
|
||||
@click="reveal(file)"
|
||||
>
|
||||
<MkDriveFileThumbnail
|
||||
:file="file"
|
||||
@@ -49,6 +44,7 @@ import * as Misskey from 'misskey-js';
|
||||
import { notePage } from '@/filters/note.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js';
|
||||
import bytes from '@/filters/bytes.js';
|
||||
|
||||
import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
|
||||
@@ -59,6 +55,24 @@ defineProps<{
|
||||
}>();
|
||||
|
||||
const showingFiles = ref<Set<string>>(new Set());
|
||||
|
||||
function isHiding(file: Misskey.entities.DriveFile) {
|
||||
if (shouldHideFileByDefault(file) && !showingFiles.value.has(file.id)) {
|
||||
if (!file.isSensitive && !file.type.startsWith('image/')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function reveal(file: Misskey.entities.DriveFile) {
|
||||
if (!(await canRevealFile(file))) {
|
||||
return;
|
||||
}
|
||||
|
||||
showingFiles.value.add(file.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
||||
@@ -90,7 +90,8 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
||||
const vote = async (id: number) => {
|
||||
if (props.readOnly || closed.value || isVoted.value) return;
|
||||
|
||||
pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
const isLoggedIn = await pleaseLogin({ openOnRemote: pleaseLoginContext.value });
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
|
||||
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<header :class="$style.header">
|
||||
<div :class="$style.headerLeft">
|
||||
<button v-if="!fixed" :class="$style.cancel" class="_button" @click="cancel"><i class="ti ti-x"></i></button>
|
||||
<button ref="accountMenuEl" v-click-anime v-tooltip="i18n.ts.account" :class="$style.account" class="_button" @click="openAccountMenu">
|
||||
<button ref="accountMenuEl" v-click-anime v-tooltip="i18n.ts.account" class="_button" @click="openAccountMenu">
|
||||
<img :class="$style.avatar" :src="(postAccount ?? $i).avatarUrl" style="border-radius: 100%;"/>
|
||||
</button>
|
||||
</div>
|
||||
@@ -329,8 +329,8 @@ const canSaveAsServerDraft = computed((): boolean => {
|
||||
return canPost.value && (textLength.value > 0 || files.value.length > 0 || poll.value != null);
|
||||
});
|
||||
|
||||
const withHashtags = computed(store.makeGetterSetter('postFormWithHashtags'));
|
||||
const hashtags = computed(store.makeGetterSetter('postFormHashtags'));
|
||||
const withHashtags = store.model('postFormWithHashtags');
|
||||
const hashtags = store.model('postFormHashtags');
|
||||
|
||||
watch(text, () => {
|
||||
checkMissingMention();
|
||||
@@ -1469,9 +1469,6 @@ defineExpose({
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.account {
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: block;
|
||||
width: 28px;
|
||||
|
||||
93
packages/frontend/src/components/MkPreviewWithControls.vue
Normal file
93
packages/frontend/src/components/MkPreviewWithControls.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<div :class="$style.container">
|
||||
<div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]">
|
||||
<div :class="$style.previewContent">
|
||||
<slot name="preview"></slot>
|
||||
</div>
|
||||
<div v-if="previewLoading" :class="$style.previewLoading">
|
||||
<MkLoading/>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.controls">
|
||||
<slot name="controls"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
previewLoading?: boolean;
|
||||
}>(), {
|
||||
previewLoading: false,
|
||||
});
|
||||
|
||||
defineSlots<{
|
||||
preview: () => any;
|
||||
controls: () => any;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
container-type: inline-size;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
position: relative;
|
||||
background-color: var(--MI_THEME-bg);
|
||||
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.previewContent {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.previewLoading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: color(from var(--MI_THEME-panel) srgb r g b / 0.7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.animatedBg {
|
||||
animation: bg 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes bg {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: -20px -20px; }
|
||||
}
|
||||
|
||||
.controls {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
@container (max-width: 800px) {
|
||||
.container {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root" class="_gaps_m">
|
||||
<div class="_gaps_m">
|
||||
<MkInput v-model="q_name" data-cy-server-name>
|
||||
<template #label>{{ i18n.ts.instanceName }}</template>
|
||||
</MkInput>
|
||||
@@ -370,8 +370,3 @@ function applySettings() {
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template>
|
||||
<div :class="$style.root" @click="(ev) => emit('click', ev)">
|
||||
<span v-if="iconClass" :class="[$style.icon, iconClass]"></span>
|
||||
<span :class="$style.content">{{ content }}</span>
|
||||
<span>{{ content }}</span>
|
||||
<MkButton v-if="exButtonIconClass" :class="$style.exButton" @click="(ev) => emit('exButtonClick', ev)">
|
||||
<span :class="[$style.exButtonIcon, exButtonIconClass]"></span>
|
||||
</MkButton>
|
||||
|
||||
@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<MkAvatar :class="$style.avatar" :user="user" indicator/>
|
||||
<div :class="$style.body">
|
||||
<span :class="$style.name"><MkUserName :user="user"/></span>
|
||||
<span :class="$style.sub"><span class="_monospace">@{{ acct(user) }}</span></span>
|
||||
<span :class="$style.sub"><slot name="sub"><span class="_monospace">@{{ acct(user) }}</span></slot></span>
|
||||
</div>
|
||||
<MkMiniChart v-if="chartValues" :class="$style.chart" :src="chartValues"/>
|
||||
</div>
|
||||
|
||||
@@ -16,50 +16,49 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
>
|
||||
<template #header><i class="ti ti-copyright"></i> {{ i18n.ts._watermarkEditor.title }}</template>
|
||||
|
||||
<div :class="$style.root">
|
||||
<div :class="$style.container">
|
||||
<div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]">
|
||||
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
|
||||
<div :class="$style.previewContainer">
|
||||
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
|
||||
<div v-if="props.image == null" class="_acrylic" :class="$style.previewControls">
|
||||
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button>
|
||||
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button>
|
||||
<button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button>
|
||||
</div>
|
||||
<MkPreviewWithControls>
|
||||
<template #preview>
|
||||
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
|
||||
<div :class="$style.previewContainer">
|
||||
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
|
||||
<div v-if="props.image == null" class="_acrylic" :class="$style.previewControls">
|
||||
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button>
|
||||
<button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button>
|
||||
<button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.controls">
|
||||
<div class="_spacer _gaps">
|
||||
<div class="_gaps_s">
|
||||
<MkFolder v-for="(layer, i) in layers" :key="layer.id" :defaultOpen="false" :canPage="false">
|
||||
<template #label>
|
||||
<div v-if="layer.type === 'text'">{{ i18n.ts._watermarkEditor.text }}</div>
|
||||
<div v-if="layer.type === 'image'">{{ i18n.ts._watermarkEditor.image }}</div>
|
||||
<div v-if="layer.type === 'qr'">{{ i18n.ts._watermarkEditor.qr }}</div>
|
||||
<div v-if="layer.type === 'stripe'">{{ i18n.ts._watermarkEditor.stripe }}</div>
|
||||
<div v-if="layer.type === 'polkadot'">{{ i18n.ts._watermarkEditor.polkadot }}</div>
|
||||
<div v-if="layer.type === 'checker'">{{ i18n.ts._watermarkEditor.checker }}</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="_buttons">
|
||||
<MkButton iconOnly @click="removeLayer(layer)"><i class="ti ti-trash"></i></MkButton>
|
||||
<MkButton iconOnly @click="swapUpLayer(layer)"><i class="ti ti-arrow-up"></i></MkButton>
|
||||
<MkButton iconOnly @click="swapDownLayer(layer)"><i class="ti ti-arrow-down"></i></MkButton>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<XLayer
|
||||
v-model:layer="layers[i]"
|
||||
></XLayer>
|
||||
</MkFolder>
|
||||
<template #controls>
|
||||
<div class="_spacer _gaps">
|
||||
<div class="_gaps_s">
|
||||
<MkFolder v-for="(layer, i) in layers" :key="layer.id" :defaultOpen="false" :canPage="false">
|
||||
<template #label>
|
||||
<div v-if="layer.type === 'text'">{{ i18n.ts._watermarkEditor.text }}</div>
|
||||
<div v-if="layer.type === 'image'">{{ i18n.ts._watermarkEditor.image }}</div>
|
||||
<div v-if="layer.type === 'qr'">{{ i18n.ts._watermarkEditor.qr }}</div>
|
||||
<div v-if="layer.type === 'stripe'">{{ i18n.ts._watermarkEditor.stripe }}</div>
|
||||
<div v-if="layer.type === 'polkadot'">{{ i18n.ts._watermarkEditor.polkadot }}</div>
|
||||
<div v-if="layer.type === 'checker'">{{ i18n.ts._watermarkEditor.checker }}</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="_buttons">
|
||||
<MkButton iconOnly @click="removeLayer(layer)"><i class="ti ti-trash"></i></MkButton>
|
||||
<MkButton iconOnly @click="swapUpLayer(layer)"><i class="ti ti-arrow-up"></i></MkButton>
|
||||
<MkButton iconOnly @click="swapDownLayer(layer)"><i class="ti ti-arrow-down"></i></MkButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<MkButton rounded primary style="margin: 0 auto;" @click="addLayer"><i class="ti ti-plus"></i></MkButton>
|
||||
</div>
|
||||
<XLayer
|
||||
v-model:layer="layers[i]"
|
||||
></XLayer>
|
||||
</MkFolder>
|
||||
|
||||
<MkButton rounded primary style="margin: 0 auto;" @click="addLayer"><i class="ti ti-plus"></i></MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</MkPreviewWithControls>
|
||||
</MkModalWindow>
|
||||
</template>
|
||||
|
||||
@@ -69,6 +68,7 @@ import type { WatermarkLayers, WatermarkPreset } from '@/utility/watermark/Water
|
||||
import { WatermarkRenderer } from '@/utility/watermark/WatermarkRenderer.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import MkPreviewWithControls from '@/components/MkPreviewWithControls.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
@@ -411,33 +411,6 @@ function removeLayer(layer: WatermarkPreset['layers'][number]) {
|
||||
</script>
|
||||
|
||||
<style module>
|
||||
.root {
|
||||
container-type: inline-size;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
position: relative;
|
||||
background-color: var(--MI_THEME-bg);
|
||||
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.animatedBg {
|
||||
animation: bg 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes bg {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: -20px -20px; }
|
||||
}
|
||||
|
||||
.previewContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -474,16 +447,6 @@ function removeLayer(layer: WatermarkPreset['layers'][number]) {
|
||||
}
|
||||
}
|
||||
|
||||
.previewSpinner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.previewCanvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -494,15 +457,4 @@ function removeLayer(layer: WatermarkPreset['layers'][number]) {
|
||||
box-sizing: border-box;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.controls {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
@container (max-width: 800px) {
|
||||
.container {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user