diff --git a/CHANGELOG.md b/CHANGELOG.md index d90b1425c..ab4ecb3ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,13 @@ - 実装の都合により、プラグインは1つエラーを起こした時に即時停止するようになりました - Enhance: ページのデザインを変更 - Enhance: 2要素認証(ワンタイムパスワード)の入力欄を改善 +- Enhance: 「今日誕生日のフォロー中ユーザー」ウィジェットを手動でリロードできるように +- Enhance: 映像・音声の再生にブラウザのネイティブプレイヤーを使用できるように +- Enhance: 映像・音声の再生メニューに「再生速度」「ループ再生」「ピクチャインピクチャ」を追加 +- Enhance: 映像・音声の再生にキーボードショートカットが使えるように +- Enhance: ノートについているリアクションの「もっと!」から、リアクションの一覧を表示できるように +- Enhance: リプライにて引用がある場合テキストが空でもノートできるように + - 引用したいノートのURLをコピーしリプライ投稿画面にペーストして添付することで達成できます - Fix: 一部のページ内リンクが正しく動作しない問題を修正 - Fix: 周年の実績が閏年を考慮しない問題を修正 - Fix: ローカルURLのプレビューポップアップが左上に表示される @@ -27,12 +34,19 @@ (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/528) - Fix: コードブロックのシンタックスハイライトで使用される定義ファイルをCDNから取得するように #13177 - CDNから取得せずMisskey本体にバンドルする場合は`pacakges/frontend/vite.config.ts`を修正してください。 +- Fix: タイムゾーンによっては、「今日誕生日のフォロー中ユーザー」ウィジェットが正しく動作しない問題を修正 +- Fix: CWのみの引用リノートが詳細ページで純粋なリノートとして誤って扱われてしまう問題を修正 +- Fix: ノート詳細ページにおいてCW付き引用リノートのCWボタンのラベルに「引用」が含まれていない問題を修正 +- Fix: ダイアログの入力で字数制限に違反していてもEnterキーが押せてしまう問題を修正 ### Server - Enhance: エンドポイント`antennas/update`の必須項目を`antennaId`のみに - Enhance: misskey-dev/summaly@5.1.0の取り込み(プレビュー生成処理の効率化) - Fix: フォローリクエストを作成する際に既存のものは削除するように (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/440) +- Fix: エンドポイント`notes/translate`のエラーを改善 +- Fix: CleanRemoteFilesProcessorService report progress from 100% (#13632) +- Fix: 一部の音声ファイルが映像ファイルとして扱われる問題を修正 ## 2024.3.1 diff --git a/locales/index.d.ts b/locales/index.d.ts index 905d01673..28f428d51 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5125,6 +5125,14 @@ export interface Locale extends ILocale { * バックアップコードを使う */ "useBackupCode": string; + /** + * アプリを起動 + */ + "launchApp": string; + /** + * 動画・音声の再生にブラウザのUIを使用する + */ + "useNativeUIForVideoAudioPlayer": string; "_bubbleGame": { /** * 遊び方 @@ -7767,13 +7775,9 @@ export interface Locale extends ILocale { */ "step1": ParameterizedString<"a" | "b">; /** - * 次に、表示されているQRコードをアプリでスキャンします。 + * 次に、表示されているQRコードをアプリでスキャンするか、ボタンをクリックして端末上でアプリを開きます。 */ "step2": string; - /** - * QRコードをクリックすると、お使いの端末にインストールされている認証アプリやキーリングに登録できます。 - */ - "step2Click": string; /** * デスクトップアプリを使用する場合は次のURIを入力します */ @@ -7866,6 +7870,10 @@ export interface Locale extends ILocale { * バックアップコードが全て使用されました。認証アプリを利用できない場合、これ以上アカウントにアクセスできなくなります。認証アプリを再登録してください。 */ "backupCodesExhaustedWarning": string; + /** + * 詳細なガイドはこちら + */ + "moreDetailedGuideHere": string; }; "_permissions": { /** @@ -9079,6 +9087,14 @@ export interface Locale extends ILocale { * ボタン */ "button": string; + /** + * 動的ブロック + */ + "dynamic": string; + /** + * このブロックは廃止されています。今後は{play}を利用してください。 + */ + "dynamicDescription": ParameterizedString<"play">; /** * ノート埋め込み */ @@ -10133,6 +10149,20 @@ export interface Locale extends ILocale { */ "summaryProxyDescription2": string; }; + "_mediaControls": { + /** + * ピクチャインピクチャ + */ + "pip": string; + /** + * 再生速度 + */ + "playbackRate": string; + /** + * ループ再生 + */ + "loop": string; + }; } declare const locales: { [lang: string]: Locale; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index fc9e3ee66..807dedecd 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1277,6 +1277,8 @@ gameRetry: "リトライ" notUsePleaseLeaveBlank: "使用しない場合は空欄にしてください" useTotp: "ワンタイムパスワードを使う" useBackupCode: "バックアップコードを使う" +launchApp: "アプリを起動" +useNativeUIForVideoAudioPlayer: "動画・音声の再生にブラウザのUIを使用する" _bubbleGame: howToPlay: "遊び方" @@ -2039,8 +2041,7 @@ _2fa: alreadyRegistered: "既に設定は完了しています。" registerTOTP: "認証アプリの設定を開始" step1: "まず、{a}や{b}などの認証アプリをお使いのデバイスにインストールします。" - step2: "次に、表示されているQRコードをアプリでスキャンします。" - step2Click: "QRコードをクリックすると、お使いの端末にインストールされている認証アプリやキーリングに登録できます。" + step2: "次に、表示されているQRコードをアプリでスキャンするか、ボタンをクリックして端末上でアプリを開きます。" step2Uri: "デスクトップアプリを使用する場合は次のURIを入力します" step3Title: "確認コードを入力" step3: "アプリに表示されている確認コード(トークン)を入力します。" @@ -2064,6 +2065,7 @@ _2fa: backupCodesDescription: "認証アプリが使用できなくなった場合、以下のバックアップコードを使ってアカウントにアクセスできます。これらのコードは必ず安全な場所に保管してください。各コードは一回だけ使用できます。" backupCodeUsedWarning: "バックアップコードが使用されました。認証アプリが使えなくなっている場合、なるべく早く認証アプリを再設定してください。" backupCodesExhaustedWarning: "バックアップコードが全て使用されました。認証アプリを利用できない場合、これ以上アカウントにアクセスできなくなります。認証アプリを再登録してください。" + moreDetailedGuideHere: "詳細なガイドはこちら" _permissions: "read:account": "アカウントの情報を見る" @@ -2393,6 +2395,8 @@ _pages: section: "セクション" image: "画像" button: "ボタン" + dynamic: "動的ブロック" + dynamicDescription: "このブロックは廃止されています。今後は{play}を利用してください。" note: "ノート埋め込み" _note: @@ -2697,3 +2701,8 @@ _urlPreviewSetting: summaryProxy: "プレビューを生成するプロキシのエンドポイント" summaryProxyDescription: "Misskey本体ではなく、サマリープロキシを使用してプレビューを生成します。" summaryProxyDescription2: "プロキシには下記パラメータがクエリ文字列として連携されます。プロキシ側がこれらをサポートしない場合、設定値は無視されます。" + +_mediaControls: + pip: "ピクチャインピクチャ" + playbackRate: "再生速度" + loop: "ループ再生" diff --git a/package.json b/package.json index c2730865f..ce58a74f5 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,9 @@ "postcss": "8.4.35", "tar": "6.2.0", "terser": "5.28.1", - "typescript": "5.3.3" + "typescript": "5.3.3", + "esbuild": "0.19.11", + "glob": "10.3.10" }, "devDependencies": { "@types/node": "^20.11.28", diff --git a/packages/backend/.swcrc b/packages/backend/.swcrc index 0504a2d38..845190b5f 100644 --- a/packages/backend/.swcrc +++ b/packages/backend/.swcrc @@ -19,5 +19,6 @@ }, "target": "es2022" }, - "minify": false + "minify": false, + "sourceMaps": "inline" } diff --git a/packages/backend/package.json b/packages/backend/package.json index 937180eed..c06a5ed1b 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -11,15 +11,15 @@ "start:test": "cross-env NODE_ENV=test node ./built/boot/entry.js", "migrate": "pnpm typeorm migration:run -d ormconfig.js", "revert": "pnpm typeorm migration:revert -d ormconfig.js", - "check:connect": "node ./check_connect.js", + "check:connect": "node ./scripts/check_connect.js", "build": "swc src -d built -D", "build:test": "swc test-server -d built-test -D --config-file test-server/.swcrc", "watch:swc": "swc src -d built -D -w", "build:tsc": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json", - "watch": "node watch.mjs", + "watch": "node ./scripts/watch.mjs", "restart": "pnpm build && pnpm start", - "dev": "nodemon -w src -e ts,js,mjs,cjs,json --exec \"cross-env NODE_ENV=development pnpm run restart\"", - "typecheck": "pnpm --filter megalodon build && tsc --noEmit", + "dev": "node ./scripts/dev.mjs", + "typecheck": "pnpm --filter megalodon build && tsc --noEmit && tsc -p test --noEmit", "eslint": "eslint --quiet \"src/**/*.ts\"", "lint": "pnpm typecheck && pnpm eslint", "jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.unit.cjs", @@ -31,7 +31,7 @@ "test:e2e": "pnpm build && pnpm build:test && pnpm jest:e2e", "test-and-coverage": "pnpm jest-and-coverage", "test-and-coverage:e2e": "pnpm build && pnpm build:test && pnpm jest-and-coverage:e2e", - "generate-api-json": "pnpm build && node ./generate_api_json.js" + "generate-api-json": "pnpm build && node ./scripts/generate_api_json.js" }, "optionalDependencies": { "@swc/core-android-arm64": "1.3.11", diff --git a/packages/backend/check_connect.js b/packages/backend/scripts/check_connect.js similarity index 85% rename from packages/backend/check_connect.js rename to packages/backend/scripts/check_connect.js index d88e649c0..ba25fd416 100644 --- a/packages/backend/check_connect.js +++ b/packages/backend/scripts/check_connect.js @@ -4,7 +4,7 @@ */ import Redis from 'ioredis'; -import { loadConfig } from './built/config.js'; +import { loadConfig } from '../built/config.js'; const config = loadConfig(); const redis = new Redis(config.redis); diff --git a/packages/backend/scripts/dev.mjs b/packages/backend/scripts/dev.mjs new file mode 100644 index 000000000..2d0de0f91 --- /dev/null +++ b/packages/backend/scripts/dev.mjs @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { execa, execaNode } from 'execa'; + +/** @type {import('execa').ExecaChildProcess | undefined} */ +let backendProcess; + +async function execBuildAssets() { + await execa('pnpm', ['run', 'build-assets'], { + cwd: '../../', + stdout: process.stdout, + stderr: process.stderr, + }) +} + +function execStart() { + // pnpm run start を呼び出したいが、windowsだとプロセスグループ単位でのkillが出来ずゾンビプロセス化するので + // 上記と同等の動きをするコマンドで子・孫プロセスを作らないようにしたい + backendProcess = execaNode('./built/boot/entry.js', [], { + stdout: process.stdout, + stderr: process.stderr, + env: { + 'NODE_ENV': 'development', + }, + }); +} + +async function killProc() { + if (backendProcess) { + backendProcess.kill(); + await new Promise(resolve => backendProcess.on('exit', resolve)); + backendProcess = undefined; + } +} + +(async () => { + execaNode( + './node_modules/nodemon/bin/nodemon.js', + [ + '-w', 'src', + '-e', 'ts,js,mjs,cjs,json', + '--exec', 'pnpm', 'run', 'build', + ], + { + stdio: [process.stdin, process.stdout, process.stderr, 'ipc'], + }) + .on('message', async (message) => { + if (message.type === 'exit') { + // かならずbuild->build-assetsの順番で呼び出したいので、 + // 少々トリッキーだがnodemonからのexitイベントを利用してbuild-assets->startを行う。 + // pnpm restartをbuildが終わる前にbuild-assetsが動いてしまうので、バラバラに呼び出す必要がある + + await killProc(); + await execBuildAssets(); + execStart(); + } + }) +})(); diff --git a/packages/backend/generate_api_json.js b/packages/backend/scripts/generate_api_json.js similarity index 70% rename from packages/backend/generate_api_json.js rename to packages/backend/scripts/generate_api_json.js index 602ced1d7..b4769ef80 100644 --- a/packages/backend/generate_api_json.js +++ b/packages/backend/scripts/generate_api_json.js @@ -3,8 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { loadConfig } from './built/config.js' -import { genOpenapiSpec } from './built/server/api/openapi/gen-spec.js' +import { loadConfig } from '../built/config.js' +import { genOpenapiSpec } from '../built/server/api/openapi/gen-spec.js' import { writeFileSync } from "node:fs"; const config = loadConfig(); diff --git a/packages/backend/watch.mjs b/packages/backend/scripts/watch.mjs similarity index 100% rename from packages/backend/watch.mjs rename to packages/backend/scripts/watch.mjs diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index 5bd885df4..b6b591d24 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -305,7 +305,7 @@ export class AccountMoveService { let resultUser: MiLocalUser | MiRemoteUser | null = null; if (this.userEntityService.isRemoteUser(dst)) { - if ((new Date()).getTime() - (dst.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) { + if (Date.now() - (dst.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) { await this.apPersonService.updatePerson(dst.uri); } dst = await this.apPersonService.fetchPerson(dst.uri) ?? dst; @@ -321,7 +321,7 @@ export class AccountMoveService { if (!src) continue; // oldAccountを探してもこのサーバーに存在しない場合はフォロー関係もないということなのでスルー if (this.userEntityService.isRemoteUser(dst)) { - if ((new Date()).getTime() - (src.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) { + if (Date.now() - (src.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) { await this.apPersonService.updatePerson(srcUri); } diff --git a/packages/backend/src/core/FileInfoService.ts b/packages/backend/src/core/FileInfoService.ts index 2d3312f24..9a9eae3ca 100644 --- a/packages/backend/src/core/FileInfoService.ts +++ b/packages/backend/src/core/FileInfoService.ts @@ -8,11 +8,13 @@ import * as crypto from 'node:crypto'; import * as stream from 'node:stream/promises'; import { Injectable } from '@nestjs/common'; import * as fileType from 'file-type'; +import FFmpeg from 'fluent-ffmpeg'; import isSvg from 'is-svg'; import probeImageSize from 'probe-image-size'; -import sharp from 'sharp'; import { sharpBmp } from '@misskey-dev/sharp-read-bmp'; import { encode } from 'blurhash'; +import { LoggerService } from '@/core/LoggerService.js'; +import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; export type FileInfo = { @@ -43,8 +45,12 @@ const TYPE_SVG = { @Injectable() export class FileInfoService { + private logger: Logger; + constructor( + private loggerService: LoggerService, ) { + this.logger = this.loggerService.getLogger('file-info'); } /** @@ -147,6 +153,34 @@ export class FileInfoService { return mime; } + /** + * ビデオファイルにビデオトラックがあるかどうかチェック + * (ない場合:m4a, webmなど) + * + * @param path ファイルパス + * @returns ビデオトラックがあるかどうか(エラー発生時は常に`true`を返す) + */ + @bindThis + private hasVideoTrackOnVideoFile(path: string): Promise { + const sublogger = this.logger.createSubLogger('ffprobe'); + sublogger.info(`Checking the video file. File path: ${path}`); + return new Promise((resolve) => { + try { + FFmpeg.ffprobe(path, (err, metadata) => { + if (err) { + sublogger.warn(`Could not check the video file. Returns true. File path: ${path}`, err); + resolve(true); + return; + } + resolve(metadata.streams.some((stream) => stream.codec_type === 'video')); + }); + } catch (err) { + sublogger.warn(`Could not check the video file. Returns true. File path: ${path}`, err as Error); + resolve(true); + } + }); + } + /** * Detect MIME Type and extension */ @@ -169,6 +203,20 @@ export class FileInfoService { return TYPE_SVG; } + if ((type.mime.startsWith('video') || type.mime === 'application/ogg') && !(await this.hasVideoTrackOnVideoFile(path))) { + const newMime = `audio/${type.mime.split('/')[1]}`; + if (newMime === 'audio/mp4') { + return { + mime: 'audio/mp4', + ext: 'm4a', + }; + } + return { + mime: newMime, + ext: type.ext, + }; + } + return { mime: this.fixMime(type.mime), ext: type.ext, diff --git a/packages/backend/src/core/PushNotificationService.ts b/packages/backend/src/core/PushNotificationService.ts index 3b706d985..6a845b951 100644 --- a/packages/backend/src/core/PushNotificationService.ts +++ b/packages/backend/src/core/PushNotificationService.ts @@ -101,7 +101,7 @@ export class PushNotificationService implements OnApplicationShutdown { type, body: (type === 'notification' || type === 'unreadAntennaNote') ? truncateBody(type, body) : body, userId, - dateTime: (new Date()).getTime(), + dateTime: Date.now(), }), { proxy: this.config.proxy, }).catch((err: any) => { diff --git a/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts b/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts index 917de8b72..728fc9e72 100644 --- a/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts +++ b/packages/backend/src/queue/processors/CleanRemoteFilesProcessorService.ts @@ -63,7 +63,7 @@ export class CleanRemoteFilesProcessorService { isLink: false, }); - job.updateProgress(deletedCount / total); + job.updateProgress(100 / total * deletedCount); } this.logger.succ('All cached remote files has been deleted.'); diff --git a/packages/backend/src/server/api/endpoints/i/import-blocking.ts b/packages/backend/src/server/api/endpoints/i/import-blocking.ts index 8ddbe5663..260610853 100644 --- a/packages/backend/src/server/api/endpoints/i/import-blocking.ts +++ b/packages/backend/src/server/api/endpoints/i/import-blocking.ts @@ -75,7 +75,7 @@ export default class extends Endpoint { // eslint- const checkMoving = await this.accountMoveService.validateAlsoKnownAs( me, - (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), + (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > Date.now(), true, ); if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); diff --git a/packages/backend/src/server/api/endpoints/i/import-following.ts b/packages/backend/src/server/api/endpoints/i/import-following.ts index 390dd9cd7..d5e824df2 100644 --- a/packages/backend/src/server/api/endpoints/i/import-following.ts +++ b/packages/backend/src/server/api/endpoints/i/import-following.ts @@ -75,7 +75,7 @@ export default class extends Endpoint { // eslint- const checkMoving = await this.accountMoveService.validateAlsoKnownAs( me, - (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), + (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > Date.now(), true, ); if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); diff --git a/packages/backend/src/server/api/endpoints/i/import-muting.ts b/packages/backend/src/server/api/endpoints/i/import-muting.ts index 51a9cdf5a..0f5800404 100644 --- a/packages/backend/src/server/api/endpoints/i/import-muting.ts +++ b/packages/backend/src/server/api/endpoints/i/import-muting.ts @@ -75,7 +75,7 @@ export default class extends Endpoint { // eslint- const checkMoving = await this.accountMoveService.validateAlsoKnownAs( me, - (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), + (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > Date.now(), true, ); if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); diff --git a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts index a3b67301a..bacdd5c88 100644 --- a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts +++ b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts @@ -74,7 +74,7 @@ export default class extends Endpoint { // eslint- const checkMoving = await this.accountMoveService.validateAlsoKnownAs( me, - (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), + (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > Date.now(), true, ); if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); diff --git a/packages/backend/src/server/api/endpoints/notes/translate.ts b/packages/backend/src/server/api/endpoints/notes/translate.ts index a935f761b..54b242dae 100644 --- a/packages/backend/src/server/api/endpoints/notes/translate.ts +++ b/packages/backend/src/server/api/endpoints/notes/translate.ts @@ -21,7 +21,7 @@ export const meta = { res: { type: 'object', - optional: false, nullable: false, + optional: true, nullable: false, properties: { sourceLang: { type: 'string' }, text: { type: 'string' }, @@ -39,6 +39,11 @@ export const meta = { code: 'NO_SUCH_NOTE', id: 'bea9b03f-36e0-49c5-a4db-627a029f8971', }, + cannotTranslateInvisibleNote: { + message: 'Cannot translate invisible note.', + code: 'CANNOT_TRANSLATE_INVISIBLE_NOTE', + id: 'ea29f2ca-c368-43b3-aaf1-5ac3e74bbe5d', + }, }, } as const; @@ -72,21 +77,20 @@ export default class extends Endpoint { // eslint- }); if (!(await this.noteEntityService.isVisibleForMe(note, me.id))) { - return 204; // TODO: 良い感じのエラー返す + throw new ApiError(meta.errors.cannotTranslateInvisibleNote); } if (note.text == null) { - return 204; + return; } const instance = await this.metaService.fetch(); - if (instance.deeplAuthKey == null && !instance.deeplFreeMode) { - return 204; // TODO: 良い感じのエラー返す + if (instance.deeplAuthKey == null && !instance.deeplFree throw new ApiError(meta.errors.unavailable); } if (instance.deeplFreeMode && !instance.deeplFreeInstance) { - return 204; + throw new ApiError(meta.errors.unavailable); } let targetLang = ps.targetLang; diff --git a/packages/backend/src/server/api/endpoints/users/following.ts b/packages/backend/src/server/api/endpoints/users/following.ts index 5d52ebba7..6b3389f0b 100644 --- a/packages/backend/src/server/api/endpoints/users/following.ts +++ b/packages/backend/src/server/api/endpoints/users/following.ts @@ -6,6 +6,7 @@ import { IsNull } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; import type { UsersRepository, FollowingsRepository, UserProfilesRepository } from '@/models/_.js'; +import { birthdaySchema } from '@/models/User.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; import { FollowingEntityService } from '@/core/entities/FollowingEntityService.js'; @@ -66,7 +67,7 @@ export const paramDef = { description: 'The local host is represented with `null`.', }, - birthday: { type: 'string', nullable: true }, + birthday: { ...birthdaySchema, nullable: true }, }, anyOf: [ { required: ['userId'] }, @@ -127,9 +128,7 @@ export default class extends Endpoint { // eslint- if (ps.birthday) { try { - const d = new Date(ps.birthday); - d.setHours(0, 0, 0, 0); - const birthday = `${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`; + 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}'`); diff --git a/packages/backend/test/e2e/note.ts b/packages/backend/test/e2e/note.ts index 11016f58a..bda31d964 100644 --- a/packages/backend/test/e2e/note.ts +++ b/packages/backend/test/e2e/note.ts @@ -8,12 +8,13 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import { MiNote } from '@/models/Note.js'; import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; -import { api, initTestDb, post, signup, uploadFile, uploadUrl } from '../utils.js'; +import { api, initTestDb, post, role, signup, uploadFile, uploadUrl } from '../utils.js'; import type * as misskey from 'misskey-js'; describe('Note', () => { let Notes: any; + let root: misskey.entities.SignupResponse; let alice: misskey.entities.SignupResponse; let bob: misskey.entities.SignupResponse; let tom: misskey.entities.SignupResponse; @@ -21,6 +22,7 @@ describe('Note', () => { beforeAll(async () => { const connection = await initTestDb(true); Notes = connection.getRepository(MiNote); + root = await signup({ username: 'root' }); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); tom = await signup({ username: 'tom', host: 'example.com' }); @@ -473,14 +475,14 @@ describe('Note', () => { value: true, }, } as any, - }, alice); + }, root); assert.strictEqual(res.status, 200); const assign = await api('admin/roles/assign', { userId: alice.id, roleId: res.body.id, - }, alice); + }, root); assert.strictEqual(assign.status, 204); assert.strictEqual(file.body!.isSensitive, false); @@ -508,11 +510,11 @@ describe('Note', () => { await api('admin/roles/unassign', { userId: alice.id, roleId: res.body.id, - }); + }, root); await api('admin/roles/delete', { roleId: res.body.id, - }, alice); + }, root); }); }); @@ -644,7 +646,7 @@ describe('Note', () => { sensitiveWords: [ 'test', ], - }, alice); + }, root); assert.strictEqual(sensitive.status, 204); @@ -663,7 +665,7 @@ describe('Note', () => { sensitiveWords: [ '/Test/i', ], - }, alice); + }, root); assert.strictEqual(sensitive.status, 204); @@ -680,7 +682,7 @@ describe('Note', () => { sensitiveWords: [ 'Test hoge', ], - }, alice); + }, root); assert.strictEqual(sensitive.status, 204); @@ -697,7 +699,7 @@ describe('Note', () => { prohibitedWords: [ 'test', ], - }, alice); + }, root); assert.strictEqual(prohibited.status, 204); @@ -716,7 +718,7 @@ describe('Note', () => { prohibitedWords: [ '/Test/i', ], - }, alice); + }, root); assert.strictEqual(prohibited.status, 204); @@ -733,7 +735,7 @@ describe('Note', () => { prohibitedWords: [ 'Test hoge', ], - }, alice); + }, root); assert.strictEqual(prohibited.status, 204); @@ -750,7 +752,7 @@ describe('Note', () => { prohibitedWords: [ 'test', ], - }, alice); + }, root); assert.strictEqual(prohibited.status, 204); @@ -785,7 +787,7 @@ describe('Note', () => { value: 0, }, } as any, - }, alice); + }, root); assert.strictEqual(res.status, 200); @@ -794,7 +796,7 @@ describe('Note', () => { const assign = await api('admin/roles/assign', { userId: alice.id, roleId: res.body.id, - }, alice); + }, root); assert.strictEqual(assign.status, 204); @@ -810,11 +812,11 @@ describe('Note', () => { await api('admin/roles/unassign', { userId: alice.id, roleId: res.body.id, - }); + }, root); await api('admin/roles/delete', { roleId: res.body.id, - }, alice); + }, root); }); test('ダイレクト投稿もエラーになる', async () => { @@ -839,7 +841,7 @@ describe('Note', () => { value: 0, }, } as any, - }, alice); + }, root); assert.strictEqual(res.status, 200); @@ -848,7 +850,7 @@ describe('Note', () => { const assign = await api('admin/roles/assign', { userId: alice.id, roleId: res.body.id, - }, alice); + }, root); assert.strictEqual(assign.status, 204); @@ -866,11 +868,11 @@ describe('Note', () => { await api('admin/roles/unassign', { userId: alice.id, roleId: res.body.id, - }); + }, root); await api('admin/roles/delete', { roleId: res.body.id, - }, alice); + }, root); }); test('ダイレクトの宛先とメンションが同じ場合は重複してカウントしない', async () => { @@ -895,7 +897,7 @@ describe('Note', () => { value: 1, }, } as any, - }, alice); + }, root); assert.strictEqual(res.status, 200); @@ -904,7 +906,7 @@ describe('Note', () => { const assign = await api('admin/roles/assign', { userId: alice.id, roleId: res.body.id, - }, alice); + }, root); assert.strictEqual(assign.status, 204); @@ -921,11 +923,11 @@ describe('Note', () => { await api('admin/roles/unassign', { userId: alice.id, roleId: res.body.id, - }); + }, root); await api('admin/roles/delete', { roleId: res.body.id, - }, alice); + }, root); }); }); @@ -960,4 +962,61 @@ describe('Note', () => { assert.strictEqual(mainNote.repliesCount, 0); }); }); + + describe('notes/translate', () => { + describe('翻訳機能の利用が許可されていない場合', () => { + let cannotTranslateRole: misskey.entities.Role; + + beforeAll(async () => { + cannotTranslateRole = await role(root, {}, { canUseTranslator: false }); + await api('admin/roles/assign', { roleId: cannotTranslateRole.id, userId: alice.id }, root); + }); + + test('翻訳機能の利用が許可されていない場合翻訳できない', async () => { + const aliceNote = await post(alice, { text: 'Hello' }); + const res = await api('notes/translate', { + noteId: aliceNote.id, + targetLang: 'ja', + }, alice); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'UNAVAILABLE'); + }); + + afterAll(async () => { + await api('admin/roles/unassign', { roleId: cannotTranslateRole.id, userId: alice.id }, root); + }); + }); + + test('存在しないノートは翻訳できない', async () => { + const res = await api('notes/translate', { noteId: 'foo', targetLang: 'ja' }, alice); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'NO_SUCH_NOTE'); + }); + + test('不可視なノートは翻訳できない', async () => { + const aliceNote = await post(alice, { visibility: 'followers', text: 'Hello' }); + const bobTranslateAttempt = await api('notes/translate', { noteId: aliceNote.id, targetLang: 'ja' }, bob); + + assert.strictEqual(bobTranslateAttempt.status, 400); + assert.strictEqual(bobTranslateAttempt.body.error.code, 'CANNOT_TRANSLATE_INVISIBLE_NOTE'); + }); + + test('text: null なノートを翻訳すると空のレスポンスが返ってくる', async () => { + const aliceNote = await post(alice, { text: null, poll: { choices: ['kinoko', 'takenoko'] } }); + const res = await api('notes/translate', { noteId: aliceNote.id, targetLang: 'ja' }, alice); + + assert.strictEqual(res.status, 204); + }); + + test('サーバーに DeepL 認証キーが登録されていない場合翻訳できない', async () => { + const aliceNote = await post(alice, { text: 'Hello' }); + const res = await api('notes/translate', { noteId: aliceNote.id, targetLang: 'ja' }, alice); + + // NOTE: デフォルトでは登録されていないので落ちる + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'UNAVAILABLE'); + }); + }); }); diff --git a/packages/backend/test/resources/kick_gaba7.m4a b/packages/backend/test/resources/kick_gaba7.m4a new file mode 100644 index 000000000..321df6349 Binary files /dev/null and b/packages/backend/test/resources/kick_gaba7.m4a differ diff --git a/packages/backend/test/unit/FileInfoService.ts b/packages/backend/test/unit/FileInfoService.ts index 7d8988606..e298ce505 100644 --- a/packages/backend/test/unit/FileInfoService.ts +++ b/packages/backend/test/unit/FileInfoService.ts @@ -14,6 +14,7 @@ import { afterAll, beforeAll, describe, test } from '@jest/globals'; import { GlobalModule } from '@/GlobalModule.js'; import { FileInfoService } from '@/core/FileInfoService.js'; //import { DI } from '@/di-symbols.js'; +import { LoggerService } from '@/core/LoggerService.js'; import type { TestingModule } from '@nestjs/testing'; import type { MockFunctionMetadata } from 'jest-mock'; @@ -33,6 +34,7 @@ describe('FileInfoService', () => { GlobalModule, ], providers: [ + LoggerService, FileInfoService, ], }) @@ -318,8 +320,26 @@ describe('FileInfoService', () => { }); }); - /* - * video/webmとして検出されてしまう + test('MPEG-4 AUDIO (M4A)', async () => { + const path = `${resources}/kick_gaba7.m4a`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + delete info.width; + delete info.height; + delete info.orientation; + assert.deepStrictEqual(info, { + size: 9817, + md5: '74c9279a4abe98789565f1dc1a541a42', + type: { + mime: 'audio/mp4', + ext: 'm4a', + }, + }); + }); + test('WEBM AUDIO', async () => { const path = `${resources}/kick_gaba7.webm`; const info = await fileInfoService.getFileInfo(path) as any; @@ -332,13 +352,12 @@ describe('FileInfoService', () => { delete info.orientation; assert.deepStrictEqual(info, { size: 8879, - md5: '3350083dec312419cfdc06c16413aca7', + md5: '53bc1adcb6acbbda67ff9bd484896438', type: { mime: 'audio/webm', ext: 'webm', }, }); }); - */ }); }); diff --git a/packages/frontend/package.json b/packages/frontend/package.json index bdccf4aba..d37ccba84 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -30,7 +30,7 @@ "@twemoji/parser": "15.0.0", "@vitejs/plugin-vue": "5.0.4", "@vue/compiler-sfc": "3.4.21", - "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.2", + "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.4", "astring": "1.8.6", "broadcast-channel": "7.0.0", "buraha": "0.0.1", diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue index c0f41b64d..c8d2797e1 100644 --- a/packages/frontend/src/components/MkButton.vue +++ b/packages/frontend/src/components/MkButton.vue @@ -23,6 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only v-else class="_button" :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike }]" :to="to ?? '#'" + :behavior="linkBehavior" @mousedown="onMousedown" >
@@ -43,6 +44,7 @@ const props = defineProps<{ inline?: boolean; link?: boolean; to?: string; + linkBehavior?: null | 'window' | 'browser'; autofocus?: boolean; wait?: boolean; danger?: boolean; diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue index acd2ea6f9..e5589813e 100644 --- a/packages/frontend/src/components/MkCode.vue +++ b/packages/frontend/src/components/MkCode.vue @@ -80,11 +80,9 @@ function copy() { .codePlaceholderRoot { display: block; width: 100%; - background: none; border: none; outline: none; font: inherit; - color: inherit; cursor: pointer; box-sizing: border-box; diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue index 5ca3c77fb..a807742bb 100644 --- a/packages/frontend/src/components/MkContextMenu.vue +++ b/packages/frontend/src/components/MkContextMenu.vue @@ -47,12 +47,12 @@ onMounted(() => { const width = rootEl.value!.offsetWidth; const height = rootEl.value!.offsetHeight; - if (left + width - window.pageXOffset >= (window.innerWidth - SCROLLBAR_THICKNESS)) { - left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.pageXOffset; + if (left + width - window.scrollX >= (window.innerWidth - SCROLLBAR_THICKNESS)) { + left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.scrollX; } - if (top + height - window.pageYOffset >= (window.innerHeight - SCROLLBAR_THICKNESS)) { - top = (window.innerHeight - SCROLLBAR_THICKNESS) - height + window.pageYOffset; + if (top + height - window.scrollY >= (window.innerHeight - SCROLLBAR_THICKNESS)) { + top = (window.innerHeight - SCROLLBAR_THICKNESS) - height + window.scrollY; } if (top < 0) { diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index b81ebbbb1..0c9c14ee5 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -161,7 +161,7 @@ function onKeydown(evt: KeyboardEvent) { } function onInputKeydown(evt: KeyboardEvent) { - if (evt.key === 'Enter') { + if (evt.key === 'Enter' && okButtonDisabledReason.value === null) { evt.preventDefault(); evt.stopPropagation(); ok(); diff --git a/packages/frontend/src/components/MkMediaAudio.vue b/packages/frontend/src/components/MkMediaAudio.vue index 23539427e..fce00e7f5 100644 --- a/packages/frontend/src/components/MkMediaAudio.vue +++ b/packages/frontend/src/components/MkMediaAudio.vue @@ -5,11 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only