diff --git a/locales/en-US.yml b/locales/en-US.yml index 816d3d611afc..956cba1895a9 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1319,6 +1319,12 @@ emailAddressLogin: "Login with email address" usernameLogin: "Login with username" autoloadDrafts: "Automatically load drafts when opening the posting form" drafts: "Drafts" +unsent: "Unsent" +schedule: "Schedule" +scheduled: "Scheduled" +unschedule: "Unschedule" +setScheduledTime: "Set scheduled time" +willBePostedAt: "Note will be posted at {x}" _bubbleGame: howToPlay: "How to play" @@ -1787,6 +1793,7 @@ _role: gtlAvailable: "Can view the global timeline" ltlAvailable: "Can view the local timeline" canPublicNote: "Can send public notes" + canScheduleNote: "Can schedule notes" canInitiateConversation: "Can mention, reply or quote" canCreateContent: "Can create contents" canUpdateContent: "Can edit contents" @@ -2474,6 +2481,9 @@ _notification: roleAssigned: "Role given" emptyPushNotificationMessage: "Push notifications have been updated" achievementEarned: "Achievement unlocked" + noteScheduled: "Note has been scheduled" + scheduledNotePosted: "Scheduled note has been posted" + scheduledNoteError: "Scheduled note has problem with posting" testNotification: "Test notification" checkNotificationBehavior: "Check notification appearance" sendTestNotification: "Send test notification" @@ -2497,6 +2507,9 @@ _notification: followRequestAccepted: "Accepted follow requests" roleAssigned: "Role given" achievementEarned: "Achievement unlocked" + noteScheduled: "Note scheduled" + scheduledNotePosted: "Scheduled note posted" + scheduledNoteError: "Problem with scheduled note" app: "Notifications from linked apps" _actions: followBack: "followed you back" diff --git a/locales/index.d.ts b/locales/index.d.ts index 956bbd25c1b0..364ad8f9d688 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5338,6 +5338,30 @@ export interface Locale extends ILocale { * 下書き */ "drafts": string; + /** + * 未送信 + */ + "unsent": string; + /** + * 予約 + */ + "schedule": string; + /** + * 予約済み + */ + "scheduled": string; + /** + * 予約を解除 + */ + "unschedule": string; + /** + * 予約日時を設定 + */ + "setScheduledTime": string; + /** + * {x}に投稿されます + */ + "willBePostedAt": ParameterizedString<"x">; "_bubbleGame": { /** * 遊び方 @@ -6991,6 +7015,10 @@ export interface Locale extends ILocale { * パブリック投稿の許可 */ "canPublicNote": string; + /** + * 予約投稿の許可 + */ + "canScheduleNote": string; /** * メンション、リプライ、引用の許可 */ @@ -9649,6 +9677,18 @@ export interface Locale extends ILocale { * 実績を獲得 */ "achievementEarned": string; + /** + * ノートが予約されました + */ + "noteScheduled": string; + /** + * 予約済みのノートが投稿されました + */ + "scheduledNotePosted": string; + /** + * 予約済みのノートを投稿できませんでした + */ + "scheduledNoteError": string; /** * 通知テスト */ @@ -9738,6 +9778,18 @@ export interface Locale extends ILocale { * 実績の獲得 */ "achievementEarned": string; + /** + * ノートが予約された + */ + "noteScheduled": string; + /** + * 予約済みのノートが投稿された + */ + "scheduledNotePosted": string; + /** + * 予約済みのノートが投稿できなかった + */ + "scheduledNoteError": string; /** * 連携アプリからの通知 */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 99db90d7e211..43bb0fea6e6a 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1328,6 +1328,12 @@ emailAddressLogin: "メールアドレスでログイン" usernameLogin: "ユーザー名でログイン" autoloadDrafts: "投稿フォームを開いたときに下書きを自動で読み込む" drafts: "下書き" +unsent: "未送信" +schedule: "予約" +scheduled: "予約済み" +unschedule: "予約を解除" +setScheduledTime: "予約日時を設定" +willBePostedAt: "{x}に投稿されます" _bubbleGame: howToPlay: "遊び方" @@ -1801,6 +1807,7 @@ _role: gtlAvailable: "グローバルタイムラインの閲覧" ltlAvailable: "ローカルタイムラインの閲覧" canPublicNote: "パブリック投稿の許可" + canScheduleNote: "予約投稿の許可" canInitiateConversation: "メンション、リプライ、引用の許可" canCreateContent: "コンテンツの作成" canUpdateContent: "コンテンツの編集" @@ -2538,6 +2545,9 @@ _notification: roleAssigned: "ロールが付与されました" emptyPushNotificationMessage: "プッシュ通知の更新をしました" achievementEarned: "実績を獲得" + noteScheduled: "ノートが予約されました" + scheduledNotePosted: "予約済みのノートが投稿されました" + scheduledNoteError: "予約済みのノートを投稿できませんでした" testNotification: "通知テスト" checkNotificationBehavior: "通知の表示を確かめる" sendTestNotification: "テスト通知を送信する" @@ -2562,6 +2572,9 @@ _notification: followRequestAccepted: "フォローが受理された" roleAssigned: "ロールが付与された" achievementEarned: "実績の獲得" + noteScheduled: "ノートが予約された" + scheduledNotePosted: "予約済みのノートが投稿された" + scheduledNoteError: "予約済みのノートが投稿できなかった" app: "連携アプリからの通知" _actions: diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index d16a30044584..b80f52423aa2 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -1316,6 +1316,12 @@ emailAddressLogin: "이메일 주소로 로그인" usernameLogin: "사용자명으로 로그인" autoloadDrafts: "글 작성 시 자동으로 임시 저장된 글 불러오기" drafts: "임시 저장" +unsent: "미전송" +schedule: "예약" +scheduled: "예약됨" +unschedule: "예약 취소" +setScheduledTime: "예약 시간 설정" +willBePostedAt: "{x}에 게시됩니다" _bubbleGame: howToPlay: "설명" @@ -1784,6 +1790,7 @@ _role: gtlAvailable: "글로벌 타임라인 보이기" ltlAvailable: "로컬 타임라인 보이기" canPublicNote: "공개 노트 허용" + canScheduleNote: "노트 예약 허용" mentionMax: "노트에 넣을 수 있는 멘션 수" canCreateContent: "컨텐츠 생성 허용" canUpdateContent: "컨텐츠 수정 허용" @@ -2456,9 +2463,12 @@ _notification: pollEnded: "투표 결과가 발표되었습니다" newNote: "새 게시물" unreadAntennaNote: "안테나 {name}" - roleAssigned: "역할이 부여 되었습니다." + roleAssigned: "역할이 부여 되었습니다" emptyPushNotificationMessage: "푸시 알림이 갱신되었습니다" achievementEarned: "도전 과제를 달성했습니다" + noteScheduled: "노트가 예약되었습니다" + scheduledNotePosted: "예약된 노트가 게시되었습니다" + scheduledNoteError: "예약된 노트를 게시하지 못했습니다" testNotification: "알림 테스트" checkNotificationBehavior: "알림 표시를 체크하기" sendTestNotification: "테스트 알림 보내기" @@ -2482,6 +2492,9 @@ _notification: followRequestAccepted: "팔로우 요청이 승인되었을 때" roleAssigned: "역할이 부여 됨" achievementEarned: "도전 과제 획득" + noteScheduled: "노트가 예약됨" + scheduledNotePosted: "예약된 노트가 게시됨" + scheduledNoteError: "예약된 노트를 게시하지 못함" app: "연동된 앱을 통한 알림" _actions: followBack: "팔로우" diff --git a/package.json b/package.json index 7331e540f5a7..6a79226733c8 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "misskey", - "version": "2024.5.0-host.6", + "version": "2024.5.0-host.7", "codename": "nasubi", "repository": { "type": "git", "url": "https://github.com/MisskeyIO/misskey.git" }, - "packageManager": "pnpm@9.15.0", + "packageManager": "pnpm@9.15.4", "workspaces": [ "packages/frontend", "packages/backend", @@ -56,26 +56,26 @@ "jpeg-js": "0.4.4", "lodash": "4.17.21", "sharp": "0.33.5", - "tough-cookie": "5.0.0", - "web-streams-polyfill": "4.0.0" + "tough-cookie": "5.1.0", + "web-streams-polyfill": "4.1.0" }, "dependencies": { "cssnano": "7.0.6", "execa": "9.5.2", "js-yaml": "4.1.0", - "postcss": "8.4.49", + "postcss": "8.5.1", "terser": "5.37.0", - "typescript": "5.7.2" + "typescript": "5.7.3" }, "devDependencies": { - "@types/node": "22.10.2", + "@types/node": "22.10.7", "@typescript-eslint/eslint-plugin": "7.10.0", "@typescript-eslint/parser": "7.10.0", "cross-env": "7.0.3", "cypress": "13.17.0", "eslint": "8.57.1", "ncp": "2.0.0", - "start-server-and-test": "2.0.9" + "start-server-and-test": "2.0.10" }, "optionalDependencies": { "@tensorflow/tfjs-core": "4.22.0" diff --git a/packages/backend/assets/tabler-badges/badges.png b/packages/backend/assets/tabler-badges/badges.png new file mode 100644 index 000000000000..f02880a7177f Binary files /dev/null and b/packages/backend/assets/tabler-badges/badges.png differ diff --git a/packages/backend/assets/tabler-badges/calendar-check.png b/packages/backend/assets/tabler-badges/calendar-check.png new file mode 100644 index 000000000000..1f1e1951ad61 Binary files /dev/null and b/packages/backend/assets/tabler-badges/calendar-check.png differ diff --git a/packages/backend/assets/tabler-badges/calendar-exclamation.png b/packages/backend/assets/tabler-badges/calendar-exclamation.png new file mode 100644 index 000000000000..7944fac88f3e Binary files /dev/null and b/packages/backend/assets/tabler-badges/calendar-exclamation.png differ diff --git a/packages/backend/assets/tabler-badges/calendar-time.png b/packages/backend/assets/tabler-badges/calendar-time.png new file mode 100644 index 000000000000..0443baee2614 Binary files /dev/null and b/packages/backend/assets/tabler-badges/calendar-time.png differ diff --git a/packages/backend/migration/1736923279563-ScheduledNote.js b/packages/backend/migration/1736923279563-ScheduledNote.js new file mode 100644 index 000000000000..9ed8f2ad144e --- /dev/null +++ b/packages/backend/migration/1736923279563-ScheduledNote.js @@ -0,0 +1,21 @@ +export class ScheduledNote1736923279563 { + name = 'ScheduledNote1736923279563' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "note_scheduled" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "scheduledAt" TIMESTAMP WITH TIME ZONE, "reason" character varying(256), "userId" character varying(32) NOT NULL, "draft" jsonb NOT NULL, CONSTRAINT "PK_14ca8fa67f70dc68ebab8900f4b" PRIMARY KEY ("id")); COMMENT ON COLUMN "note_scheduled"."createdAt" IS 'The created date of the Note.'; COMMENT ON COLUMN "note_scheduled"."scheduledAt" IS 'The scheduled date of the Note.'; COMMENT ON COLUMN "note_scheduled"."userId" IS 'The ID of author.'`); + await queryRunner.query(`CREATE INDEX "IDX_7ddf8710a9faee81081592ec35" ON "note_scheduled" ("createdAt") `); + await queryRunner.query(`CREATE INDEX "IDX_bbe52891059217fc31e73e84e2" ON "note_scheduled" ("scheduledAt") `); + await queryRunner.query(`CREATE INDEX "IDX_b148b24837cc7a2707ae1f0975" ON "note_scheduled" ("userId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_dfeab22d6bbc4799193997553a" ON "note_scheduled" ("userId", "scheduledAt") `); + await queryRunner.query(`ALTER TABLE "note_scheduled" ADD CONSTRAINT "FK_b148b24837cc7a2707ae1f0975a" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note_scheduled" DROP CONSTRAINT "FK_b148b24837cc7a2707ae1f0975a"`); + await queryRunner.query(`DROP INDEX "public"."IDX_dfeab22d6bbc4799193997553a"`); + await queryRunner.query(`DROP INDEX "public"."IDX_b148b24837cc7a2707ae1f0975"`); + await queryRunner.query(`DROP INDEX "public"."IDX_bbe52891059217fc31e73e84e2"`); + await queryRunner.query(`DROP INDEX "public"."IDX_7ddf8710a9faee81081592ec35"`); + await queryRunner.query(`DROP TABLE "note_scheduled"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index bf08f631f7c3..5af6ed5654c0 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -33,16 +33,16 @@ "generate-api-json": "pnpm build && node ./scripts/generate_api_json.js" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.10.3", - "@swc/core-darwin-x64": "1.10.3", - "@swc/core-linux-arm-gnueabihf": "1.10.3", - "@swc/core-linux-arm64-gnu": "1.10.3", - "@swc/core-linux-arm64-musl": "1.10.3", - "@swc/core-linux-x64-gnu": "1.10.3", - "@swc/core-linux-x64-musl": "1.10.3", - "@swc/core-win32-arm64-msvc": "1.10.3", - "@swc/core-win32-ia32-msvc": "1.10.3", - "@swc/core-win32-x64-msvc": "1.10.3", + "@swc/core-darwin-arm64": "1.10.7", + "@swc/core-darwin-x64": "1.10.7", + "@swc/core-linux-arm-gnueabihf": "1.10.7", + "@swc/core-linux-arm64-gnu": "1.10.7", + "@swc/core-linux-arm64-musl": "1.10.7", + "@swc/core-linux-x64-gnu": "1.10.7", + "@swc/core-linux-x64-musl": "1.10.7", + "@swc/core-win32-arm64-msvc": "1.10.7", + "@swc/core-win32-ia32-msvc": "1.10.7", + "@swc/core-win32-x64-msvc": "1.10.7", "@tensorflow/tfjs": "4.22.0", "@tensorflow/tfjs-node": "4.22.0", "bufferutil": "4.0.9", @@ -63,22 +63,22 @@ }, "dependencies": { "@authenio/samlify-node-xmllint": "2.0.0", - "@aws-sdk/client-s3": "3.717.0", - "@aws-sdk/lib-storage": "3.717.0", - "@bull-board/api": "6.5.4", - "@bull-board/fastify": "6.5.4", - "@bull-board/ui": "6.5.4", + "@aws-sdk/client-s3": "3.729.0", + "@aws-sdk/lib-storage": "3.729.0", + "@bull-board/api": "6.6.2", + "@bull-board/fastify": "6.6.2", + "@bull-board/ui": "6.6.2", "@discordapp/twemoji": "15.1.0", "@elastic/elasticsearch": "8.17.0", "@fastify/accepts": "5.0.2", - "@fastify/cookie": "11.0.1", - "@fastify/cors": "10.0.1", - "@fastify/express": "4.0.1", - "@fastify/formbody": "8.0.1", - "@fastify/http-proxy": "11.0.0", - "@fastify/multipart": "9.0.1", - "@fastify/static": "8.0.3", - "@fastify/view": "10.0.1", + "@fastify/cookie": "11.0.2", + "@fastify/cors": "10.0.2", + "@fastify/express": "4.0.2", + "@fastify/formbody": "8.0.2", + "@fastify/http-proxy": "11.0.1", + "@fastify/multipart": "9.0.2", + "@fastify/static": "8.0.4", + "@fastify/view": "10.0.2", "@misskey-dev/sharp-read-bmp": "1.2.0", "@misskey-dev/summaly": "github:MisskeyIO/summaly#5.1.3", "@napi-rs/canvas": "0.1.65", @@ -86,11 +86,11 @@ "@nestjs/core": "10.4.15", "@nestjs/testing": "10.4.15", "@peertube/http-signature": "1.7.0", - "@simplewebauthn/server": "13.0.0", + "@simplewebauthn/server": "13.1.0", "@sinonjs/fake-timers": "11.3.1", - "@smithy/node-http-handler": "3.3.3", - "@swc/cli": "0.5.2", - "@swc/core": "1.10.3", + "@smithy/node-http-handler": "4.0.2", + "@swc/cli": "0.6.0", + "@swc/core": "1.10.7", "@twemoji/parser": "15.1.1", "accepts": "1.3.8", "ajv": "8.17.1", @@ -99,7 +99,7 @@ "bcryptjs": "2.4.3", "blurhash": "2.0.5", "body-parser": "1.20.3", - "bullmq": "5.34.5", + "bullmq": "5.34.10", "cacheable-lookup": "7.0.0", "cbor": "10.0.3", "chalk": "5.4.1", @@ -110,11 +110,11 @@ "content-disposition": "0.5.4", "date-fns": "4.1.0", "deep-email-validator": "0.1.21", - "fastify": "5.2.0", - "fastify-http-errors-enhanced": "6.0.0", + "fastify": "5.2.1", + "fastify-http-errors-enhanced": "6.0.1", "fastify-raw-body": "5.0.0", "feed": "4.2.2", - "file-type": "19.6.0", + "file-type": "20.0.0", "fluent-ffmpeg": "2.1.3", "form-data": "4.0.1", "got": "14.4.5", @@ -127,11 +127,11 @@ "is-svg": "5.1.0", "jose": "5.9.6", "js-yaml": "4.1.0", - "jsdom": "25.0.1", + "jsdom": "26.0.0", "json5": "2.2.3", "jsonld": "8.3.3", "jsrsasign": "11.1.0", - "meilisearch": "0.47.0", + "meilisearch": "0.48.0", "mfm-js": "0.24.0", "microformats-parser": "2.0.2", "mime-types": "2.1.35", @@ -163,25 +163,24 @@ "random-seed": "0.3.0", "ratelimiter": "3.4.1", "re2": "1.21.4", - "redis-lock": "0.1.4", "reflect-metadata": "0.2.2", "rename": "1.0.4", "rss-parser": "3.13.0", "rxjs": "7.8.1", "samlify": "2.8.11", "sanitize-html": "2.14.0", - "secure-json-parse": "3.0.1", + "secure-json-parse": "3.0.2", "sharp": "0.33.5", "slacc": "0.0.10", "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", - "systeminformation": "5.23.23", + "systeminformation": "5.25.11", "tinycolor2": "1.6.0", "tmp": "0.2.3", "tsc-alias": "1.8.10", "tsconfig-paths": "4.2.0", "typeorm": "0.3.20", - "typescript": "5.7.2", + "typescript": "5.7.3", "ulid": "2.3.0", "vary": "1.1.2", "web-push": "3.6.7", @@ -210,7 +209,7 @@ "@types/jsrsasign": "10.5.15", "@types/mime-types": "2.1.4", "@types/ms": "0.7.34", - "@types/node": "22.10.2", + "@types/node": "22.10.7", "@types/node-forge": "1.3.11", "@types/nodemailer": "6.4.17", "@types/oauth": "0.9.6", diff --git a/packages/backend/src/@types/redis-lock.d.ts b/packages/backend/src/@types/redis-lock.d.ts deleted file mode 100644 index b037cde5ee6d..000000000000 --- a/packages/backend/src/@types/redis-lock.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -declare module 'redis-lock' { - import type Redis from 'ioredis'; - - type Lock = (lockName: string, timeout?: number, taskToPerform?: () => Promise) => void; - function redisLock(client: Redis.Redis, retryDelay: number): Lock; - - export = redisLock; -} diff --git a/packages/backend/src/core/AppLockService.ts b/packages/backend/src/core/AppLockService.ts deleted file mode 100644 index bd2749cb871e..000000000000 --- a/packages/backend/src/core/AppLockService.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { promisify } from 'node:util'; -import { Inject, Injectable } from '@nestjs/common'; -import redisLock from 'redis-lock'; -import * as Redis from 'ioredis'; -import { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; - -/** - * Retry delay (ms) for lock acquisition - */ -const retryDelay = 100; - -@Injectable() -export class AppLockService { - private lock: (key: string, timeout?: number, _?: (() => Promise) | undefined) => Promise<() => void>; - - constructor( - @Inject(DI.redis) - private redisClient: Redis.Redis, - ) { - this.lock = promisify(redisLock(this.redisClient, retryDelay)); - } - - /** - * Get AP Object lock - * @param uri AP object ID - * @param timeout Lock timeout (ms), The timeout releases previous lock. - * @returns Unlock function - */ - @bindThis - public getApLock(uri: string, timeout = 30 * 1000): Promise<() => void> { - return this.lock(`ap-object:${uri}`, timeout); - } - - @bindThis - public getChartInsertLock(lockKey: string, timeout = 30 * 1000): Promise<() => void> { - return this.lock(`chart-insert:${lockKey}`, timeout); - } -} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index eaa828091588..7fd89f58d156 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -10,7 +10,6 @@ import { AccountUpdateService } from './AccountUpdateService.js'; import { AiService } from './AiService.js'; import { AnnouncementService } from './AnnouncementService.js'; import { AntennaService } from './AntennaService.js'; -import { AppLockService } from './AppLockService.js'; import { AchievementService } from './AchievementService.js'; import { AvatarDecorationService } from './AvatarDecorationService.js'; import { CaptchaService } from './CaptchaService.js'; @@ -105,6 +104,7 @@ import { UserAccountMoveLogEntityService } from './entities/UserAccountMoveLogEn import { MutingEntityService } from './entities/MutingEntityService.js'; import { RenoteMutingEntityService } from './entities/RenoteMutingEntityService.js'; import { NoteEntityService } from './entities/NoteEntityService.js'; +import { ScheduledNoteEntityService } from './entities/ScheduledNoteEntityService.js'; import { NoteFavoriteEntityService } from './entities/NoteFavoriteEntityService.js'; import { NoteReactionEntityService } from './entities/NoteReactionEntityService.js'; import { NotificationEntityService } from './entities/NotificationEntityService.js'; @@ -149,7 +149,6 @@ const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useEx const $AiService: Provider = { provide: 'AiService', useExisting: AiService }; const $AnnouncementService: Provider = { provide: 'AnnouncementService', useExisting: AnnouncementService }; const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService }; -const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppLockService }; const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService }; const $AvatarDecorationService: Provider = { provide: 'AvatarDecorationService', useExisting: AvatarDecorationService }; const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: CaptchaService }; @@ -247,6 +246,7 @@ const $UserAccountMoveLogEntityService: Provider = { provide: 'UserAccountMoveLo const $MutingEntityService: Provider = { provide: 'MutingEntityService', useExisting: MutingEntityService }; const $RenoteMutingEntityService: Provider = { provide: 'RenoteMutingEntityService', useExisting: RenoteMutingEntityService }; const $NoteEntityService: Provider = { provide: 'NoteEntityService', useExisting: NoteEntityService }; +const $ScheduledNoteEntityService: Provider = { provide: 'ScheduledNoteEntityService', useExisting: ScheduledNoteEntityService }; const $NoteFavoriteEntityService: Provider = { provide: 'NoteFavoriteEntityService', useExisting: NoteFavoriteEntityService }; const $NoteReactionEntityService: Provider = { provide: 'NoteReactionEntityService', useExisting: NoteReactionEntityService }; const $NotificationEntityService: Provider = { provide: 'NotificationEntityService', useExisting: NotificationEntityService }; @@ -292,7 +292,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AiService, AnnouncementService, AntennaService, - AppLockService, AchievementService, AvatarDecorationService, CaptchaService, @@ -388,6 +387,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting MutingEntityService, RenoteMutingEntityService, NoteEntityService, + ScheduledNoteEntityService, NoteFavoriteEntityService, NoteReactionEntityService, NotificationEntityService, @@ -429,7 +429,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AiService, $AnnouncementService, $AntennaService, - $AppLockService, $AchievementService, $AvatarDecorationService, $CaptchaService, @@ -525,6 +524,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $MutingEntityService, $RenoteMutingEntityService, $NoteEntityService, + $ScheduledNoteEntityService, $NoteFavoriteEntityService, $NoteReactionEntityService, $NotificationEntityService, @@ -567,7 +567,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AiService, AnnouncementService, AntennaService, - AppLockService, AchievementService, AvatarDecorationService, CaptchaService, @@ -662,6 +661,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting MutingEntityService, RenoteMutingEntityService, NoteEntityService, + ScheduledNoteEntityService, NoteFavoriteEntityService, NoteReactionEntityService, NotificationEntityService, @@ -703,7 +703,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AiService, $AnnouncementService, $AntennaService, - $AppLockService, $AchievementService, $AvatarDecorationService, $CaptchaService, @@ -798,6 +797,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $MutingEntityService, $RenoteMutingEntityService, $NoteEntityService, + $ScheduledNoteEntityService, $NoteFavoriteEntityService, $NoteReactionEntityService, $NotificationEntityService, diff --git a/packages/backend/src/core/FileInfoService.ts b/packages/backend/src/core/FileInfoService.ts index 576eb089c878..cd1cc1511b84 100644 --- a/packages/backend/src/core/FileInfoService.ts +++ b/packages/backend/src/core/FileInfoService.ts @@ -9,7 +9,7 @@ import { join } from 'node:path'; import * as stream from 'node:stream/promises'; import { Injectable } from '@nestjs/common'; import { FSWatcher } from 'chokidar'; -import * as fileType from 'file-type'; +import { fileTypeFromFile } from 'file-type'; import FFmpeg from 'fluent-ffmpeg'; import isSvg from 'is-svg'; import probeImageSize from 'probe-image-size'; @@ -309,7 +309,7 @@ export class FileInfoService { } @bindThis - public fixMime(mime: string | fileType.MimeType): string { + public fixMime(mime: string): string { // see https://github.com/misskey-dev/misskey/pull/10686 if (mime === 'audio/x-flac') { return 'audio/flac'; @@ -363,7 +363,7 @@ export class FileInfoService { return TYPE_OCTET_STREAM; } - const type = await fileType.fileTypeFromFile(path); + const type = await fileTypeFromFile(path); if (type) { // XMLはSVGかもしれない diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index ea8b9432f522..71e430c2d4a9 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -13,16 +13,28 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf import { extractHashtags } from '@/misc/extract-hashtags.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js'; -import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import { MiScheduledNote } from '@/models/ScheduledNote.js'; +import type { + ChannelFollowingsRepository, + ChannelsRepository, + FollowingsRepository, + InstancesRepository, + MiFollowing, + NotesRepository, + NoteThreadMutingsRepository, + ScheduledNotesRepository, + UserListMembershipsRepository, + UserProfilesRepository, + UsersRepository +} from '@/models/_.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; -import type { MiApp } from '@/models/App.js'; import { concat } from '@/misc/prelude/array.js'; import { IdService } from '@/core/IdService.js'; import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js'; import type { IPoll } from '@/models/Poll.js'; import { MiPoll } from '@/models/Poll.js'; +import type { NoteCreateOption, MinimumUser } from '@/types.js'; import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; -import type { MiChannel } from '@/models/Channel.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { RelayService } from '@/core/RelayService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; @@ -118,35 +130,6 @@ class NotificationManager { } } -type MinimumUser = { - id: MiUser['id']; - host: MiUser['host']; - username: MiUser['username']; - uri: MiUser['uri']; -}; - -type Option = { - createdAt?: Date | null; - name?: string | null; - text?: string | null; - reply?: MiNote | null; - renote?: MiNote | null; - files?: MiDriveFile[] | null; - poll?: IPoll | null; - localOnly?: boolean | null; - reactionAcceptance?: MiNote['reactionAcceptance']; - cw?: string | null; - visibility?: string; - visibleUsers?: MinimumUser[] | null; - channel?: MiChannel | null; - apMentions?: MinimumUser[] | null; - apHashtags?: string[] | null; - apEmojis?: string[] | null; - uri?: string | null; - url?: string | null; - app?: MiApp | null; -}; - @Injectable() export class NoteCreateService implements OnApplicationShutdown { private logger: Logger; @@ -169,6 +152,9 @@ export class NoteCreateService implements OnApplicationShutdown { @Inject(DI.notesRepository) private notesRepository: NotesRepository, + @Inject(DI.scheduledNotesRepository) + private scheduledNotesRepository: ScheduledNotesRepository, + @Inject(DI.instancesRepository) private instancesRepository: InstancesRepository, @@ -229,7 +215,7 @@ export class NoteCreateService implements OnApplicationShutdown { host: MiUser['host']; isBot: MiUser['isBot']; isCat: MiUser['isCat']; - }, data: Option, silent = false): Promise { + }, data: NoteCreateOption, silent = false): Promise { // チャンネル外にリプライしたら対象のスコープに合わせる // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) if (data.reply && data.channel && data.reply.channelId !== data.channel.id) { @@ -425,18 +411,30 @@ export class NoteCreateService implements OnApplicationShutdown { throw new IdentifiableError('9f466dab-c856-48cd-9e65-ff90ff750580', `Notes including mentions are limited to ${policies.mentionLimit} users.`); } - const note = await this.insertNote(user, data, tags, emojis, mentionedUsers); + if (!data.scheduledAt) { + const note = await this.insertNote(user, data, tags, emojis, mentionedUsers); - setImmediate('post created', { signal: this.#shutdownController.signal }).then( - () => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!), - () => { /* aborted, ignore this */ }, - ); + setImmediate('post created', { signal: this.#shutdownController.signal }).then( + () => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!), + () => { /* aborted, ignore this */ }, + ); + + return note; + } else { + if (!policies.canScheduleNote) { + throw new IdentifiableError('7cc42034-f7ab-4f7c-87b4-e00854479080', 'User has no permission to schedule notes.'); + } + + const draft = await this.insertScheduledNote(user, data); - return note; + await this.queueService.createScheduledNoteJob(draft.id, draft.scheduledAt!); + + return draft; + } } @bindThis - private async insertNote(user: { id: MiUser['id']; host: MiUser['host']; }, data: Option, tags: string[], emojis: string[], mentionedUsers: MinimumUser[]) { + private async insertNote(user: { id: MiUser['id']; host: MiUser['host']; }, data: NoteCreateOption, tags: string[], emojis: string[], mentionedUsers: MinimumUser[]) { const insert = new MiNote({ id: this.idService.gen(data.createdAt?.getTime()), createdAt: data.createdAt!, @@ -534,13 +532,40 @@ export class NoteCreateService implements OnApplicationShutdown { } } + @bindThis + private async insertScheduledNote(user: { id: MiUser['id']; host: MiUser['host']; }, data: NoteCreateOption) { + const insert = new MiScheduledNote({ + id: this.idService.gen(data.createdAt?.getTime()), + createdAt: data.createdAt!, + scheduledAt: data.scheduledAt!, + userId: user.id, + draft: data, + }); + + // 予約投稿を作成 + try { + await this.scheduledNotesRepository.insert(insert); + + return insert; + } catch (e) { + // duplicate key error + if (isDuplicateKeyValueError(e)) { + throw new IdentifiableError('5ea8e4f5-9d64-4e6c-92b8-9e2b5a4756bc', 'There is already a scheduled note with the same time.'); + } + + this.logger.error(`Failed to create scheduled note: ${e}`, { error: e }); + + throw e; + } + } + @bindThis private async postNoteCreated(note: MiNote, user: { id: MiUser['id']; username: MiUser['username']; host: MiUser['host']; isBot: MiUser['isBot']; - }, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) { + }, data: NoteCreateOption, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) { const meta = await this.metaService.fetch(); this.notesChart.update(note, true); @@ -792,12 +817,12 @@ export class NoteCreateService implements OnApplicationShutdown { } @bindThis - private isRenote(note: Option): note is Option & { renote: MiNote } { + private isRenote(note: NoteCreateOption): note is NoteCreateOption & { renote: MiNote } { return note.renote != null; } @bindThis - private isQuote(note: Option): note is Option & { renote: MiNote } & ( + private isQuote(note: NoteCreateOption): note is NoteCreateOption & { renote: MiNote } & ( { text: string } | { cw: string } | { reply: MiNote } | { poll: IPoll } | { files: MiDriveFile[] } ) { // NOTE: SYNC WITH misc/is-renote.ts @@ -873,7 +898,7 @@ export class NoteCreateService implements OnApplicationShutdown { } @bindThis - private async renderNoteOrRenoteActivity(data: Option, note: MiNote) { + private async renderNoteOrRenoteActivity(data: NoteCreateOption, note: MiNote) { if (data.localOnly) return null; const content = this.isRenote(data) && !this.isQuote(data) diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 526751044869..fb5501c42cbe 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; import type { IActivity } from '@/core/activitypub/type.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; +import type { MiScheduledNote } from '@/models/ScheduledNote.js'; import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; import type { MiWebhook, webhookEventTypes } from '@/models/Webhook.js'; import type { Config } from '@/config.js'; @@ -34,6 +35,11 @@ export class QueueService { @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, @Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue, ) { + this.ensureRepeatJobs(); + } + + @bindThis + private ensureRepeatJobs() { this.systemQueue.add('tickCharts', { }, { repeat: { pattern: '55 * * * *' }, @@ -69,6 +75,12 @@ export class QueueService { repeat: { pattern: '*/5 * * * *' }, removeOnComplete: true, }); + + this.systemQueue.add('checkMissingScheduledNote', { + }, { + repeat: { pattern: '*/5 * * * *' }, + removeOnComplete: true, + }); } @bindThis @@ -376,7 +388,22 @@ export class QueueService { @bindThis public createReportAbuseJob(report: MiAbuseUserReport) { - return this.dbQueue.add('reportAbuse', report); + return this.dbQueue.add('reportAbuse', report, { + removeOnComplete: true, + removeOnFail: true, + }); + } + + @bindThis + public createScheduledNoteJob(draftId: MiScheduledNote['id'], scheduledAt: Date) { + return this.systemQueue.add('scheduledNote', { + draftId, + }, { + jobId: `scheduledNote:${draftId}`, + delay: Math.max(scheduledAt.getTime() - Date.now(), 0) + Math.floor(Math.random() * 500 + 250), + removeOnComplete: true, + removeOnFail: true, + }); } @bindThis diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 1c205dca0bcf..1b8091cc24c7 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -36,6 +36,7 @@ export type RolePolicies = { gtlAvailable: boolean; ltlAvailable: boolean; canPublicNote: boolean; + canScheduleNote: boolean; canInitiateConversation: boolean; canCreateContent: boolean; canUpdateContent: boolean; @@ -77,6 +78,7 @@ export const DEFAULT_POLICIES: RolePolicies = { gtlAvailable: true, ltlAvailable: true, canPublicNote: true, + canScheduleNote: true, canInitiateConversation: true, canCreateContent: true, canUpdateContent: true, @@ -389,6 +391,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { gtlAvailable: calc('gtlAvailable', vs => vs.some(v => v === true)), ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)), canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)), + canScheduleNote: calc('canScheduleNote', vs => vs.some(v => v === true)), canInitiateConversation: calc('canInitiateConversation', vs => vs.some(v => v === true)), canCreateContent: calc('canCreateContent', vs => vs.some(v => v === true)), canUpdateContent: calc('canUpdateContent', vs => vs.some(v => v === true)), diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 9542eabe572e..291abd05f445 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -5,6 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { In } from 'typeorm'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; @@ -14,8 +15,8 @@ import { NotePiningService } from '@/core/NotePiningService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; import { NoteDeleteService } from '@/core/NoteDeleteService.js'; import { NoteCreateService } from '@/core/NoteCreateService.js'; +import { acquireApObjectLock } from '@/misc/distributed-lock.js'; import { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js'; -import { AppLockService } from '@/core/AppLockService.js'; import type Logger from '@/logger.js'; import { MetaService } from '@/core/MetaService.js'; import { IdService } from '@/core/IdService.js'; @@ -49,6 +50,9 @@ export class ApInboxService { @Inject(DI.config) private config: Config, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -77,7 +81,6 @@ export class ApInboxService { private userBlockingService: UserBlockingService, private noteCreateService: NoteCreateService, private noteDeleteService: NoteDeleteService, - private appLockService: AppLockService, private apResolverService: ApResolverService, private apDbResolverService: ApDbResolverService, private apLoggerService: ApLoggerService, @@ -312,7 +315,7 @@ export class ApInboxService { const meta = await this.metaService.fetch(); if (this.utilityService.isItemListedIn(this.utilityService.extractHost(uri), meta.blockedHosts)) return 'skip: blocked host'; - const unlock = await this.appLockService.getApLock(uri); + const unlock = await acquireApObjectLock(this.redisForTimelines, uri); try { // 既に同じURIを持つものが登録されていないかチェック @@ -440,7 +443,7 @@ export class ApInboxService { } } - const unlock = await this.appLockService.getApLock(uri); + const unlock = await acquireApObjectLock(this.redisForTimelines, uri); try { const exist = await this.apNoteService.fetchNote(note); @@ -543,7 +546,7 @@ export class ApInboxService { private async deleteNote(actor: MiRemoteUser, uri: string): Promise { this.logger.info(`Deleting the Note: ${uri}`); - const unlock = await this.appLockService.getApLock(uri); + const unlock = await acquireApObjectLock(this.redisForTimelines, uri); try { const note = await this.apDbResolverService.getNoteFromApId(uri); @@ -813,7 +816,7 @@ export class ApInboxService { return 'ok: Question updated'; } else if (additionalCc && isPost(object)) { const uri = getApId(object); - const unlock = await this.appLockService.getApLock(uri); + const unlock = await acquireApObjectLock(this.redisForTimelines, uri); try { const exist = await this.apNoteService.fetchNote(object); diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 03c91b3c42c8..9effcfac9f1d 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -5,15 +5,16 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { In } from 'typeorm'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import type { UsersRepository, PollsRepository, EmojisRepository } from '@/models/_.js'; import type { Config } from '@/config.js'; import type { MiRemoteUser } from '@/models/User.js'; import type { MiNote } from '@/models/Note.js'; +import { acquireApObjectLock } from '@/misc/distributed-lock.js'; import { toArray, toSingle, unique } from '@/misc/prelude/array.js'; import type { MiEmoji } from '@/models/Emoji.js'; import { MetaService } from '@/core/MetaService.js'; -import { AppLockService } from '@/core/AppLockService.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import { NoteCreateService } from '@/core/NoteCreateService.js'; import type Logger from '@/logger.js'; @@ -47,6 +48,9 @@ export class ApNoteService { @Inject(DI.config) private config: Config, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -70,7 +74,6 @@ export class ApNoteService { private apImageService: ApImageService, private apQuestionService: ApQuestionService, private metaService: MetaService, - private appLockService: AppLockService, private pollService: PollService, private noteCreateService: NoteCreateService, private apDbResolverService: ApDbResolverService, @@ -349,7 +352,7 @@ export class ApNoteService { poll, uri: note.id, url: url, - }, silent); + }, silent) as MiNote; } catch (err: any) { if (err.name !== 'duplicated') { throw err; @@ -379,7 +382,7 @@ export class ApNoteService { throw new StatusError('blocked host', 451); } - const unlock = await this.appLockService.getApLock(uri); + const unlock = await acquireApObjectLock(this.redisForTimelines, uri); try { //#region このサーバーに既に登録されていたらそれを返す diff --git a/packages/backend/src/core/chart/charts/active-users.ts b/packages/backend/src/core/chart/charts/active-users.ts index 05905f378293..0f118ef7f73f 100644 --- a/packages/backend/src/core/chart/charts/active-users.ts +++ b/packages/backend/src/core/chart/charts/active-users.ts @@ -5,11 +5,12 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import { AppLockService } from '@/core/AppLockService.js'; +import * as Redis from 'ioredis'; import type { MiUser } from '@/models/User.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/active-users.js'; @@ -28,11 +29,13 @@ export default class ActiveUsersChart extends Chart { // eslint-d @Inject(DI.db) private db: DataSource, - private appLockService: AppLockService, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + private chartLoggerService: ChartLoggerService, private idService: IdService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema); + super(db, (k) => acquireChartInsertLock(redisForTimelines, k), chartLoggerService.logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/chart/charts/ap-request.ts b/packages/backend/src/core/chart/charts/ap-request.ts index 04e771a95b4e..401bcdca6423 100644 --- a/packages/backend/src/core/chart/charts/ap-request.ts +++ b/packages/backend/src/core/chart/charts/ap-request.ts @@ -5,9 +5,10 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import { AppLockService } from '@/core/AppLockService.js'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/ap-request.js'; @@ -22,10 +23,12 @@ export default class ApRequestChart extends Chart { // eslint-dis @Inject(DI.db) private db: DataSource, - private appLockService: AppLockService, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema); + super(db, (k) => acquireChartInsertLock(redisForTimelines, k), chartLoggerService.logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/chart/charts/drive.ts b/packages/backend/src/core/chart/charts/drive.ts index 613e074a9f1d..552afec8928b 100644 --- a/packages/backend/src/core/chart/charts/drive.ts +++ b/packages/backend/src/core/chart/charts/drive.ts @@ -5,10 +5,11 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import type { MiDriveFile } from '@/models/DriveFile.js'; -import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/drive.js'; @@ -23,10 +24,12 @@ export default class DriveChart extends Chart { // eslint-disable @Inject(DI.db) private db: DataSource, - private appLockService: AppLockService, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema); + super(db, (k) => acquireChartInsertLock(redisForTimelines, k), chartLoggerService.logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/chart/charts/federation.ts b/packages/backend/src/core/chart/charts/federation.ts index 5e4555ee96f5..106874b9df12 100644 --- a/packages/backend/src/core/chart/charts/federation.ts +++ b/packages/backend/src/core/chart/charts/federation.ts @@ -5,11 +5,12 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import type { FollowingsRepository, InstancesRepository } from '@/models/_.js'; -import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import { MetaService } from '@/core/MetaService.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/federation.js'; @@ -24,6 +25,9 @@ export default class FederationChart extends Chart { // eslint-di @Inject(DI.db) private db: DataSource, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, @@ -31,10 +35,9 @@ export default class FederationChart extends Chart { // eslint-di private instancesRepository: InstancesRepository, private metaService: MetaService, - private appLockService: AppLockService, private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema); + super(db, (k) => acquireChartInsertLock(redisForTimelines, k), chartLoggerService.logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/chart/charts/instance.ts b/packages/backend/src/core/chart/charts/instance.ts index 037c4dd08281..de0bc4270478 100644 --- a/packages/backend/src/core/chart/charts/instance.ts +++ b/packages/backend/src/core/chart/charts/instance.ts @@ -5,13 +5,14 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import type { DriveFilesRepository, FollowingsRepository, UsersRepository, NotesRepository } from '@/models/_.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiNote } from '@/models/Note.js'; -import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/instance.js'; @@ -26,6 +27,9 @@ export default class InstanceChart extends Chart { // eslint-disa @Inject(DI.db) private db: DataSource, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -39,10 +43,9 @@ export default class InstanceChart extends Chart { // eslint-disa private followingsRepository: FollowingsRepository, private utilityService: UtilityService, - private appLockService: AppLockService, private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); + super(db, (k) => acquireChartInsertLock(redisForTimelines, k), chartLoggerService.logger, name, schema, true); } protected async tickMajor(group: string): Promise>> { diff --git a/packages/backend/src/core/chart/charts/notes.ts b/packages/backend/src/core/chart/charts/notes.ts index f763b5fffa73..c6258ccc53c1 100644 --- a/packages/backend/src/core/chart/charts/notes.ts +++ b/packages/backend/src/core/chart/charts/notes.ts @@ -5,11 +5,12 @@ import { Injectable, Inject } from '@nestjs/common'; import { Not, IsNull, DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import type { NotesRepository } from '@/models/_.js'; import type { MiNote } from '@/models/Note.js'; -import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/notes.js'; @@ -24,13 +25,15 @@ export default class NotesChart extends Chart { // eslint-disable @Inject(DI.db) private db: DataSource, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, - private appLockService: AppLockService, private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema); + super(db, (k) => acquireChartInsertLock(redisForTimelines, k), chartLoggerService.logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/chart/charts/per-user-drive.ts b/packages/backend/src/core/chart/charts/per-user-drive.ts index 404964d8b7c5..070ae055f372 100644 --- a/packages/backend/src/core/chart/charts/per-user-drive.ts +++ b/packages/backend/src/core/chart/charts/per-user-drive.ts @@ -5,12 +5,13 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import type { DriveFilesRepository } from '@/models/_.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; -import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/per-user-drive.js'; @@ -25,14 +26,16 @@ export default class PerUserDriveChart extends Chart { // eslint- @Inject(DI.db) private db: DataSource, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - private appLockService: AppLockService, private driveFileEntityService: DriveFileEntityService, private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); + super(db, (k) => acquireChartInsertLock(redisForTimelines, k), chartLoggerService.logger, name, schema, true); } protected async tickMajor(group: string): Promise>> { diff --git a/packages/backend/src/core/chart/charts/per-user-following.ts b/packages/backend/src/core/chart/charts/per-user-following.ts index 588ac638de90..cbac5cfb1fce 100644 --- a/packages/backend/src/core/chart/charts/per-user-following.ts +++ b/packages/backend/src/core/chart/charts/per-user-following.ts @@ -5,12 +5,13 @@ import { Injectable, Inject } from '@nestjs/common'; import { Not, IsNull, DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import type { MiUser } from '@/models/User.js'; -import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { FollowingsRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/per-user-following.js'; @@ -25,14 +26,16 @@ export default class PerUserFollowingChart extends Chart { // esl @Inject(DI.db) private db: DataSource, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, - private appLockService: AppLockService, private userEntityService: UserEntityService, private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); + super(db, (k) => acquireChartInsertLock(redisForTimelines, k), chartLoggerService.logger, name, schema, true); } protected async tickMajor(group: string): Promise>> { diff --git a/packages/backend/src/core/chart/charts/per-user-notes.ts b/packages/backend/src/core/chart/charts/per-user-notes.ts index e4900772bb9f..e66dcd974351 100644 --- a/packages/backend/src/core/chart/charts/per-user-notes.ts +++ b/packages/backend/src/core/chart/charts/per-user-notes.ts @@ -5,12 +5,13 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import type { MiUser } from '@/models/User.js'; import type { MiNote } from '@/models/Note.js'; -import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import type { NotesRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/per-user-notes.js'; @@ -25,13 +26,15 @@ export default class PerUserNotesChart extends Chart { // eslint- @Inject(DI.db) private db: DataSource, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, - private appLockService: AppLockService, private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); + super(db, (k) => acquireChartInsertLock(redisForTimelines, k), chartLoggerService.logger, name, schema, true); } protected async tickMajor(group: string): Promise>> { diff --git a/packages/backend/src/core/chart/charts/per-user-pv.ts b/packages/backend/src/core/chart/charts/per-user-pv.ts index 3ad90b090441..6d498c469405 100644 --- a/packages/backend/src/core/chart/charts/per-user-pv.ts +++ b/packages/backend/src/core/chart/charts/per-user-pv.ts @@ -5,11 +5,12 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import type { MiUser } from '@/models/User.js'; -import { AppLockService } from '@/core/AppLockService.js'; import { addTime, dateUTC, subtractTime } from '@/misc/prelude/time.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/per-user-pv.js'; @@ -24,10 +25,12 @@ export default class PerUserPvChart extends Chart { // eslint-dis @Inject(DI.db) private db: DataSource, - private appLockService: AppLockService, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); + super(db, (k) => acquireChartInsertLock(redisForTimelines, k), chartLoggerService.logger, name, schema, true); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/chart/charts/per-user-reactions.ts b/packages/backend/src/core/chart/charts/per-user-reactions.ts index c29c4d28709e..a07945710bac 100644 --- a/packages/backend/src/core/chart/charts/per-user-reactions.ts +++ b/packages/backend/src/core/chart/charts/per-user-reactions.ts @@ -5,12 +5,13 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import type { MiUser } from '@/models/User.js'; import type { MiNote } from '@/models/Note.js'; -import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/per-user-reactions.js'; @@ -25,11 +26,13 @@ export default class PerUserReactionsChart extends Chart { // esl @Inject(DI.db) private db: DataSource, - private appLockService: AppLockService, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + private userEntityService: UserEntityService, private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); + super(db, (k) => acquireChartInsertLock(redisForTimelines, k), chartLoggerService.logger, name, schema, true); } protected async tickMajor(group: string): Promise>> { diff --git a/packages/backend/src/core/chart/charts/test-grouped.ts b/packages/backend/src/core/chart/charts/test-grouped.ts index 7a2844f4ed41..7492e59141e7 100644 --- a/packages/backend/src/core/chart/charts/test-grouped.ts +++ b/packages/backend/src/core/chart/charts/test-grouped.ts @@ -5,10 +5,11 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import { AppLockService } from '@/core/AppLockService.js'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { name, schema } from './entities/test-grouped.js'; import type { KVs } from '../core.js'; @@ -24,10 +25,12 @@ export default class TestGroupedChart extends Chart { // eslint-d @Inject(DI.db) private db: DataSource, - private appLockService: AppLockService, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + logger: Logger, ) { - super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema, true); + super(db, (k) => acquireChartInsertLock(redisForTimelines, k), logger, name, schema, true); } protected async tickMajor(group: string): Promise>> { diff --git a/packages/backend/src/core/chart/charts/test-intersection.ts b/packages/backend/src/core/chart/charts/test-intersection.ts index b8d0556c9fac..cf96622d17d0 100644 --- a/packages/backend/src/core/chart/charts/test-intersection.ts +++ b/packages/backend/src/core/chart/charts/test-intersection.ts @@ -5,10 +5,11 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import { AppLockService } from '@/core/AppLockService.js'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { name, schema } from './entities/test-intersection.js'; import type { KVs } from '../core.js'; @@ -22,10 +23,12 @@ export default class TestIntersectionChart extends Chart { // esl @Inject(DI.db) private db: DataSource, - private appLockService: AppLockService, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + logger: Logger, ) { - super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema); + super(db, (k) => acquireChartInsertLock(redisForTimelines, k), logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/chart/charts/test-unique.ts b/packages/backend/src/core/chart/charts/test-unique.ts index f94e008059a3..7c8b651b3920 100644 --- a/packages/backend/src/core/chart/charts/test-unique.ts +++ b/packages/backend/src/core/chart/charts/test-unique.ts @@ -5,10 +5,11 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import { AppLockService } from '@/core/AppLockService.js'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { name, schema } from './entities/test-unique.js'; import type { KVs } from '../core.js'; @@ -22,10 +23,12 @@ export default class TestUniqueChart extends Chart { // eslint-di @Inject(DI.db) private db: DataSource, - private appLockService: AppLockService, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + logger: Logger, ) { - super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema); + super(db, (k) => acquireChartInsertLock(redisForTimelines, k), logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/chart/charts/test.ts b/packages/backend/src/core/chart/charts/test.ts index a90dc8f99b4f..4dee2ccefea6 100644 --- a/packages/backend/src/core/chart/charts/test.ts +++ b/packages/backend/src/core/chart/charts/test.ts @@ -5,10 +5,11 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import { AppLockService } from '@/core/AppLockService.js'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { name, schema } from './entities/test.js'; import type { KVs } from '../core.js'; @@ -24,10 +25,12 @@ export default class TestChart extends Chart { // eslint-disable- @Inject(DI.db) private db: DataSource, - private appLockService: AppLockService, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + logger: Logger, ) { - super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema); + super(db, (k) => acquireChartInsertLock(redisForTimelines, k), logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/chart/charts/users.ts b/packages/backend/src/core/chart/charts/users.ts index d148fc629b1d..0c7c95f429c6 100644 --- a/packages/backend/src/core/chart/charts/users.ts +++ b/packages/backend/src/core/chart/charts/users.ts @@ -5,12 +5,13 @@ import { Injectable, Inject } from '@nestjs/common'; import { Not, IsNull, DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import type { MiUser } from '@/models/User.js'; -import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { UsersRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/users.js'; @@ -25,14 +26,16 @@ export default class UsersChart extends Chart { // eslint-disable @Inject(DI.db) private db: DataSource, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, - private appLockService: AppLockService, private userEntityService: UserEntityService, private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema); + super(db, (k) => acquireChartInsertLock(redisForTimelines, k), chartLoggerService.logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index a580ce44d92d..bd8f9a1cf7bd 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -20,14 +20,16 @@ import { RoleEntityService } from './RoleEntityService.js'; import type { OnModuleInit } from '@nestjs/common'; import type { UserEntityService } from './UserEntityService.js'; import type { NoteEntityService } from './NoteEntityService.js'; +import type { ScheduledNoteEntityService } from './ScheduledNoteEntityService.js'; -const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded'] as (typeof groupedNotificationTypes[number])[]); +const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded', 'scheduledNotePosted'] as (typeof groupedNotificationTypes[number])[]); @Injectable() export class NotificationEntityService implements OnModuleInit { private userEntityService: UserEntityService; private noteEntityService: NoteEntityService; private roleEntityService: RoleEntityService; + private scheduledNoteEntityService: ScheduledNoteEntityService; constructor( private moduleRef: ModuleRef, @@ -52,6 +54,7 @@ export class NotificationEntityService implements OnModuleInit { this.userEntityService = this.moduleRef.get('UserEntityService'); this.noteEntityService = this.moduleRef.get('NoteEntityService'); this.roleEntityService = this.moduleRef.get('RoleEntityService'); + this.scheduledNoteEntityService = this.moduleRef.get('ScheduledNoteEntityService'); } /** @@ -84,6 +87,11 @@ export class NotificationEntityService implements OnModuleInit { // if the note has been deleted, don't show this notification if (needsNote && !noteIfNeed) return null; + const needsDraft = 'draftId' in notification; + const draftIfNeed = needsDraft ? this.scheduledNoteEntityService.pack(notification.draftId, { id: meId }) : undefined; + // if the draft has been deleted, don't show this notification + if (needsDraft && !draftIfNeed) return null; + const needsUser = 'notifierId' in notification; const userIfNeed = needsUser ? ( hint?.packedUsers != null @@ -116,6 +124,7 @@ export class NotificationEntityService implements OnModuleInit { createdAt: new Date(notification.createdAt).toISOString(), type: notification.type, note: noteIfNeed, + draft: draftIfNeed, reactions, }); } else if (notification.type === 'renote:grouped') { @@ -139,6 +148,7 @@ export class NotificationEntityService implements OnModuleInit { createdAt: new Date(notification.createdAt).toISOString(), type: notification.type, note: noteIfNeed, + draft: draftIfNeed, users, }); } @@ -158,6 +168,7 @@ export class NotificationEntityService implements OnModuleInit { userId: 'notifierId' in notification ? notification.notifierId : undefined, ...(userIfNeed != null ? { user: userIfNeed } : {}), ...(noteIfNeed != null ? { note: noteIfNeed } : {}), + ...(draftIfNeed != null ? { draft: draftIfNeed } : {}), ...(notification.type === 'reaction' ? { reaction: notification.reaction, } : {}), diff --git a/packages/backend/src/core/entities/ScheduledNoteEntityService.ts b/packages/backend/src/core/entities/ScheduledNoteEntityService.ts new file mode 100644 index 000000000000..07f1c2bcafea --- /dev/null +++ b/packages/backend/src/core/entities/ScheduledNoteEntityService.ts @@ -0,0 +1,76 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { ScheduledNotesRepository } from '@/models/_.js'; +import type { MiUser } from '@/models/User.js'; +import type { MiScheduledNote } from '@/models/ScheduledNote.js'; +import { bindThis } from '@/decorators.js'; +import { Packed } from '@/misc/json-schema.js'; +import { DriveFileEntityService } from './DriveFileEntityService.js'; + +@Injectable() +export class ScheduledNoteEntityService { + constructor( + @Inject(DI.scheduledNotesRepository) + private scheduledNotesRepository: ScheduledNotesRepository, + + private driveFileEntityService: DriveFileEntityService, + ) { + } + + @bindThis + public async pack( + src: MiScheduledNote['id'] | MiScheduledNote, + me: { id: MiUser['id'] }, + ) : Promise> { + const item = typeof src === 'object' ? src : await this.scheduledNotesRepository.findOneByOrFail({ id: src, userId: me.id }); + + return { + id: item.id, + updatedAt: item.createdAt.toISOString(), + scheduledAt: item.scheduledAt?.toISOString() ?? null, + reason: item.reason ?? undefined, + channel: item.draft.channel ? { + id: item.draft.channel.id, + name: item.draft.channel.name, + } : undefined, + renote: item.draft.renote ? { + id: item.draft.renote.id, + text: (item.draft.renote.cw ?? item.draft.renote.text)?.substring(0, 100) ?? null, + user: { + id: item.draft.renote.userId, + username: item.draft.renote.user!.username, + host: item.draft.renote.user!.host, + }, + } : undefined, + reply: item.draft.reply ? { + id: item.draft.reply.id, + text: (item.draft.reply.cw ?? item.draft.reply.text)?.substring(0, 100) ?? null, + user: { + id: item.draft.reply.userId, + username: item.draft.reply.user!.username, + host: item.draft.reply.user!.host, + }, + } : undefined, + data: { + text: item.draft.text ?? null, + useCw: !!item.draft.cw, + cw: item.draft.cw ?? null, + visibility: item.draft.visibility as 'public' | 'followers' | 'home' | 'specified', + localOnly: item.draft.localOnly ?? false, + files: item.draft.files ? await this.driveFileEntityService.packMany(item.draft.files, me) : [], + poll: item.draft.poll ? { ...item.draft.poll, expiresAt: item.draft.poll.expiresAt?.getTime() ?? null, expiredAfter: null } : null, + visibleUserIds: item.draft.visibility === 'specified' ? item.draft.visibleUsers?.map(x => x.id) : undefined, + }, + }; + } + + @bindThis + public async packMany( + drafts: (MiScheduledNote['id'] | MiScheduledNote)[], + me: { id: MiUser['id'] }, + ) : Promise[]> { + return (await Promise.allSettled(drafts.map(x => this.pack(x, me)))) + .filter(result => result.status === 'fulfilled') + .map(result => (result as PromiseFulfilledResult>).value); + } +} diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index a70cce451b16..8b7b10b92593 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -21,6 +21,7 @@ export const DI = { announcementReadsRepository: Symbol('announcementReadsRepository'), appsRepository: Symbol('appsRepository'), avatarDecorationsRepository: Symbol('avatarDecorationsRepository'), + scheduledNotesRepository: Symbol('scheduledNotesRepository'), noteFavoritesRepository: Symbol('noteFavoritesRepository'), noteThreadMutingsRepository: Symbol('noteThreadMutingsRepository'), noteReactionsRepository: Symbol('noteReactionsRepository'), diff --git a/packages/backend/src/misc/distributed-lock.ts b/packages/backend/src/misc/distributed-lock.ts new file mode 100644 index 000000000000..0d7dfb1592ed --- /dev/null +++ b/packages/backend/src/misc/distributed-lock.ts @@ -0,0 +1,44 @@ +import * as Redis from 'ioredis'; + +export async function acquireDistributedLock( + redis: Redis.Redis, + name: string, + timeout: number, + maxRetries: number, + retryInterval: number, +): Promise<() => Promise> { + const lockKey = `lock:${name}`; + const identifier = Math.random().toString(36).slice(2); + + let retries = 0; + while (retries < maxRetries) { + const result = await redis.set(lockKey, identifier, 'PX', timeout, 'NX'); + if (result === 'OK') { + return async () => { + const currentIdentifier = await redis.get(lockKey); + if (currentIdentifier === identifier) { + await redis.del(lockKey); + } + }; + } + + await new Promise(resolve => setTimeout(resolve, retryInterval)); + retries++; + } + + throw new Error(`Failed to acquire lock ${name}`); +} + +export function acquireApObjectLock( + redis: Redis.Redis, + uri: string, +): Promise<() => Promise> { + return acquireDistributedLock(redis, `ap-object:${uri}`, 30 * 1000, 600, 100); +} + +export function acquireChartInsertLock( + redis: Redis.Redis, + name: string, +): Promise<() => Promise> { + return acquireDistributedLock(redis, `chart-insert:${name}`, 30 * 1000, 120, 500); +} diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index 5f169c37dfce..10b144b16136 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -31,6 +31,7 @@ import { packedMutingSchema } from '@/models/json-schema/muting.js'; import { packedNoteFavoriteSchema } from '@/models/json-schema/note-favorite.js'; import { packedNoteReactionSchema } from '@/models/json-schema/note-reaction.js'; import { packedNoteSchema } from '@/models/json-schema/note.js'; +import { packedNoteDraftSchema } from '@/models/json-schema/note-draft.js'; import { packedNotificationSchema } from '@/models/json-schema/notification.js'; import { packedPageLikeSchema, packedPageBlockSchema, packedPageSchema } from '@/models/json-schema/page.js'; import { packedQueueCountSchema } from '@/models/json-schema/queue.js'; @@ -77,6 +78,7 @@ export const refs = { Announcement: packedAnnouncementSchema, App: packedAppSchema, Note: packedNoteSchema, + NoteDraft: packedNoteDraftSchema, NoteReaction: packedNoteReactionSchema, NoteFavorite: packedNoteFavoriteSchema, Notification: packedNotificationSchema, diff --git a/packages/backend/src/models/Notification.ts b/packages/backend/src/models/Notification.ts index df88b996364c..4747b51b5fc7 100644 --- a/packages/backend/src/models/Notification.ts +++ b/packages/backend/src/models/Notification.ts @@ -5,6 +5,7 @@ import { MiUser } from './User.js'; import { MiNote } from './Note.js'; +import { MiScheduledNote } from './ScheduledNote.js'; import { MiAccessToken } from './AccessToken.js'; import { MiRole } from './Role.js'; @@ -77,6 +78,21 @@ export type MiNotification = { id: string; createdAt: string; achievement: string; +} | { + type: 'noteScheduled'; + id: string; + createdAt: string; + draftId: MiScheduledNote['id']; +} | { + type: 'scheduledNotePosted'; + id: string; + createdAt: string; + noteId: MiNote['id']; +} | { + type: 'scheduledNoteError'; + id: string; + createdAt: string; + draftId: MiScheduledNote['id']; } | { type: 'app'; id: string; diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 83eee55ff890..ced1ff812310 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -39,6 +39,7 @@ import { MiModerationLog, MiMuting, MiNote, + MiScheduledNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, @@ -117,6 +118,12 @@ const $avatarDecorationsRepository: Provider = { inject: [DI.db], }; +const $scheduledNotesRepository: Provider = { + provide: DI.scheduledNotesRepository, + useFactory: (db: DataSource) => db.getRepository(MiScheduledNote), + inject: [DI.db], +}; + const $noteFavoritesRepository: Provider = { provide: DI.noteFavoritesRepository, useFactory: (db: DataSource) => db.getRepository(MiNoteFavorite), @@ -517,6 +524,7 @@ const $abuseReportResolversRepository: Provider = { $announcementReadsRepository, $appsRepository, $avatarDecorationsRepository, + $scheduledNotesRepository, $noteFavoritesRepository, $noteThreadMutingsRepository, $noteReactionsRepository, @@ -590,6 +598,7 @@ const $abuseReportResolversRepository: Provider = { $announcementReadsRepository, $appsRepository, $avatarDecorationsRepository, + $scheduledNotesRepository, $noteFavoritesRepository, $noteThreadMutingsRepository, $noteReactionsRepository, diff --git a/packages/backend/src/models/ScheduledNote.ts b/packages/backend/src/models/ScheduledNote.ts new file mode 100644 index 000000000000..d573e251497b --- /dev/null +++ b/packages/backend/src/models/ScheduledNote.ts @@ -0,0 +1,54 @@ +import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; +import type { NoteCreateOption } from '@/types.js'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; + +@Entity('note_scheduled') +@Index(['userId', 'scheduledAt'], { unique: true }) +export class MiScheduledNote { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the Note.', + default: () => 'CURRENT_TIMESTAMP', + }) + public createdAt: Date; + + @Index() + @Column('timestamp with time zone', { + comment: 'The scheduled date of the Note.', + nullable: true + }) + public scheduledAt: Date | null; + + @Column('varchar', { + length: 256, nullable: true, + }) + public reason: string | null; + + @Index() + @Column({ + ...id(), + comment: 'The ID of author.', + }) + public userId: MiUser['id']; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: MiUser | null; + + @Column('jsonb') + public draft: NoteCreateOption; + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index 800af98b1f30..7bf282a1829f 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -34,6 +34,7 @@ import { MiModerationLog } from '@/models/ModerationLog.js'; import { MiMuting } from '@/models/Muting.js'; import { MiRenoteMuting } from '@/models/RenoteMuting.js'; import { MiNote } from '@/models/Note.js'; +import { MiScheduledNote } from '@/models/ScheduledNote.js'; import { MiNoteFavorite } from '@/models/NoteFavorite.js'; import { MiNoteReaction } from '@/models/NoteReaction.js'; import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js'; @@ -108,6 +109,7 @@ export { MiMuting, MiRenoteMuting, MiNote, + MiScheduledNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, @@ -181,6 +183,7 @@ export type ModerationLogsRepository = Repository; export type MutingsRepository = Repository; export type RenoteMutingsRepository = Repository; export type NotesRepository = Repository; +export type ScheduledNotesRepository = Repository; export type NoteFavoritesRepository = Repository; export type NoteReactionsRepository = Repository; export type NoteThreadMutingsRepository = Repository; diff --git a/packages/backend/src/models/json-schema/note-draft.ts b/packages/backend/src/models/json-schema/note-draft.ts new file mode 100644 index 000000000000..7b81838fe087 --- /dev/null +++ b/packages/backend/src/models/json-schema/note-draft.ts @@ -0,0 +1,179 @@ +export const packedNoteDraftSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'misskey:id' + }, + updatedAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time' + }, + scheduledAt: { + type: 'string', + optional: false, nullable: true, + format: 'date-time', + }, + reason: { + type: 'string', + optional: true, nullable: false + }, + channel: { + type: 'object', + optional: true, nullable: true, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'misskey:id' + }, + name: { + type: 'string', + optional: false, nullable: false + }, + }, + }, + renote: { + type: 'object', + optional: true, nullable: true, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'misskey:id', + }, + text: { + type: 'string', + optional: false, nullable: true, + }, + user: { + type: 'object', + optional: false, nullable: false, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'misskey:id', + }, + username: { + type: 'string', + optional: false, nullable: false, + }, + host: { + type: 'string', + optional: false, nullable: true, + }, + }, + }, + }, + }, + reply: { + type: 'object', + optional: true, nullable: true, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'misskey:id', + }, + text: { + type: 'string', + optional: false, nullable: true, + }, + user: { + type: 'object', + optional: false, nullable: false, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'misskey:id' + }, + username: { + type: 'string', + optional: false, nullable: false, + }, + host: { + type: 'string', + optional: false, nullable: true, + }, + }, + }, + }, + }, + data: { + type: 'object', + optional: false, nullable: false, + properties: { + text: { + type: 'string', + optional: false, nullable: true, + }, + useCw: { + type: 'boolean', + optional: false, nullable: false, + }, + cw: { + type: 'string', + optional: false, nullable: true, + }, + visibility: { + type: 'string', + optional: false, nullable: false, + enum: ['public', 'home', 'followers', 'specified'], + }, + localOnly: { + type: 'boolean', + optional: false, nullable: false, + }, + files: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'DriveFile', + }, + }, + poll: { + type: 'object', + optional: false, nullable: true, + properties: { + choices: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, + multiple: { + type: 'boolean', + optional: false, nullable: false, + }, + expiresAt: { + type: 'integer', + optional: false, nullable: true, + }, + expiredAfter: { + type: 'integer', + optional: false, nullable: true, + minimum: 1 + }, + }, + }, + visibleUserIds: { + type: 'array', + optional: true, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'misskey:id', + }, + }, + }, + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts index b4c4442758cd..e682408977d1 100644 --- a/packages/backend/src/models/json-schema/notification.ts +++ b/packages/backend/src/models/json-schema/notification.ts @@ -296,6 +296,51 @@ export const packedNotificationSchema = { optional: false, nullable: false, }, }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['noteScheduled'], + }, + draft: { + type: 'object', + ref: 'NoteDraft', + optional: false, nullable: false, + } + } + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['scheduledNotePosted'], + }, + note: { + type: 'object', + ref: 'Note', + optional: false, nullable: false, + } + } + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['scheduledNoteError'], + }, + draft: { + type: 'object', + ref: 'NoteDraft', + optional: false, nullable: false, + } + } }, { type: 'object', properties: { diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index 166a085f5546..6cbea17089ca 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -180,6 +180,10 @@ export const packedRolePoliciesSchema = { type: 'boolean', optional: false, nullable: false, }, + canScheduleNote: { + type: 'boolean', + optional: false, nullable: false, + }, canInitiateConversation: { type: 'boolean', optional: false, nullable: false, diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 58a40ae3c8b4..b272f7d6523e 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -44,6 +44,7 @@ import { MiModerationLog } from '@/models/ModerationLog.js'; import { MiMuting } from '@/models/Muting.js'; import { MiRenoteMuting } from '@/models/RenoteMuting.js'; import { MiNote } from '@/models/Note.js'; +import { MiScheduledNote } from '@/models/ScheduledNote.js'; import { MiNoteFavorite } from '@/models/NoteFavorite.js'; import { MiNoteReaction } from '@/models/NoteReaction.js'; import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js'; @@ -162,6 +163,7 @@ export const entities = [ MiRenoteMuting, MiBlocking, MiNote, + MiScheduledNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts index f3588544fc7d..d7f0a56e7286 100644 --- a/packages/backend/src/queue/QueueProcessorModule.ts +++ b/packages/backend/src/queue/QueueProcessorModule.ts @@ -13,6 +13,7 @@ import { EndedPollNotificationProcessorService } from './processors/EndedPollNot import { InboxProcessorService } from './processors/InboxProcessorService.js'; import { WebhookDeliverProcessorService } from './processors/WebhookDeliverProcessorService.js'; import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js'; +import { CheckMissingScheduledNoteProcessorService } from './processors/CheckMissingScheduledNoteProcessorService.js'; import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js'; import { CleanProcessorService } from './processors/CleanProcessorService.js'; import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesProcessorService.js'; @@ -36,6 +37,7 @@ import { ImportUserListsProcessorService } from './processors/ImportUserListsPro import { ImportAntennasProcessorService } from './processors/ImportAntennasProcessorService.js'; import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js'; import { ReportAbuseProcessorService } from './processors/ReportAbuseProcessorService.js'; +import { ScheduledNoteProcessorService } from './processors/ScheduledNoteProcessorService.js'; import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js'; import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js'; @@ -52,6 +54,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor ResyncChartsProcessorService, CleanChartsProcessorService, CheckExpiredMutingsProcessorService, + CheckMissingScheduledNoteProcessorService, CleanProcessorService, DeleteDriveFilesProcessorService, ExportCustomEmojisProcessorService, @@ -75,6 +78,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor CleanRemoteFilesProcessorService, RelationshipProcessorService, ReportAbuseProcessorService, + ScheduledNoteProcessorService, WebhookDeliverProcessorService, EndedPollNotificationProcessorService, DeliverProcessorService, diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 1e68f750abcd..dc1429944a76 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -35,10 +35,12 @@ import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesP import { DeleteFileProcessorService } from './processors/DeleteFileProcessorService.js'; import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js'; import { ReportAbuseProcessorService } from './processors/ReportAbuseProcessorService.js'; -import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js'; import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js'; +import { ScheduledNoteProcessorService } from './processors/ScheduledNoteProcessorService.js'; +import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js'; import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js'; import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js'; +import { CheckMissingScheduledNoteProcessorService } from './processors/CheckMissingScheduledNoteProcessorService.js'; import { CleanProcessorService } from './processors/CleanProcessorService.js'; import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; import { QueueLoggerService } from './QueueLoggerService.js'; @@ -113,11 +115,13 @@ export class QueueProcessorService implements OnApplicationShutdown { private cleanRemoteFilesProcessorService: CleanRemoteFilesProcessorService, private relationshipProcessorService: RelationshipProcessorService, private reportAbuseProcessorService: ReportAbuseProcessorService, - private tickChartsProcessorService: TickChartsProcessorService, private resyncChartsProcessorService: ResyncChartsProcessorService, + private scheduledNoteProcessorService: ScheduledNoteProcessorService, + private tickChartsProcessorService: TickChartsProcessorService, private cleanChartsProcessorService: CleanChartsProcessorService, private aggregateRetentionProcessorService: AggregateRetentionProcessorService, private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService, + private checkMissingScheduledNoteProcessorService: CheckMissingScheduledNoteProcessorService, private cleanProcessorService: CleanProcessorService, ) { this.logger = this.queueLoggerService.logger; @@ -141,11 +145,13 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region system this.systemQueueWorker = new Bull.Worker(QUEUE.SYSTEM, (job) => { switch (job.name) { + case 'scheduledNote': return this.scheduledNoteProcessorService.process(job); case 'tickCharts': return this.tickChartsProcessorService.process(); case 'resyncCharts': return this.resyncChartsProcessorService.process(); case 'cleanCharts': return this.cleanChartsProcessorService.process(); case 'aggregateRetention': return this.aggregateRetentionProcessorService.process(); case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process(); + case 'checkMissingScheduledNote': return this.checkMissingScheduledNoteProcessorService.process(); case 'clean': return this.cleanProcessorService.process(); default: throw new Error(`unrecognized job type ${job.name} for system`); } diff --git a/packages/backend/src/queue/processors/CheckMissingScheduledNoteProcessorService.ts b/packages/backend/src/queue/processors/CheckMissingScheduledNoteProcessorService.ts new file mode 100644 index 000000000000..8c441a9cc4a7 --- /dev/null +++ b/packages/backend/src/queue/processors/CheckMissingScheduledNoteProcessorService.ts @@ -0,0 +1,61 @@ +import { Inject, Injectable } from '@nestjs/common'; +import * as Redis from 'ioredis'; +import { DI } from '@/di-symbols.js'; +import type { ScheduledNotesRepository } from '@/models/_.js'; +import type Logger from '@/logger.js'; +import { bindThis } from '@/decorators.js'; +import { acquireDistributedLock } from '@/misc/distributed-lock.js'; +import { QueueService } from '@/core/QueueService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; + +@Injectable() +export class CheckMissingScheduledNoteProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + + @Inject(DI.scheduledNotesRepository) + private scheduledNotesRepository: ScheduledNotesRepository, + + private queueService: QueueService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('note:scheduled'); + } + + @bindThis + public async process(): Promise { + this.logger.info(`checking missing scheduled note tasks`); + + try { + await acquireDistributedLock(this.redisForTimelines, `note:scheduled:check`, 3 * 60 * 1000, 1, 1000); + } catch (e) { + this.logger.warn(`check is already being processed`); + return; + } + + const query = this.scheduledNotesRepository.createQueryBuilder('draft') + .where('draft.scheduledAt < now() - interval \'5 minutes\'').orderBy('draft.createdAt', 'ASC'); + + let lastId = '0'; + while (true) { + const drafts = await query.andWhere('draft.id > :lastId', { lastId }).limit(100).getMany(); + + if (drafts.length === 0) { + break; + } + + for (const draft of drafts.filter(draft => draft.scheduledAt !== null)) { + const jobState = await this.queueService.systemQueue.getJobState(`scheduledNote:${draft.id}`); + if (jobState !== 'unknown') continue; + + this.logger.warn(`found missing scheduled note task: ${draft.id}`); + await this.queueService.createScheduledNoteJob(draft.id, draft.scheduledAt!); + } + + lastId = drafts[drafts.length - 1].id; + } + } +} diff --git a/packages/backend/src/queue/processors/ScheduledNoteProcessorService.ts b/packages/backend/src/queue/processors/ScheduledNoteProcessorService.ts new file mode 100644 index 000000000000..3ca28941eb85 --- /dev/null +++ b/packages/backend/src/queue/processors/ScheduledNoteProcessorService.ts @@ -0,0 +1,101 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull } from 'typeorm'; +import * as Bull from 'bullmq'; +import * as Redis from 'ioredis'; +import { DI } from '@/di-symbols.js'; +import { MiNote, type ScheduledNotesRepository } from '@/models/_.js'; +import type Logger from '@/logger.js'; +import { bindThis } from '@/decorators.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { acquireDistributedLock } from '@/misc/distributed-lock.js'; +import { NotificationService } from '@/core/NotificationService.js'; +import { NoteCreateService } from '@/core/NoteCreateService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type { ScheduledNoteJobData } from '../types.js'; + +@Injectable() +export class ScheduledNoteProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + + @Inject(DI.scheduledNotesRepository) + private scheduledNotesRepository: ScheduledNotesRepository, + + private notificationService: NotificationService, + private noteCreateService: NoteCreateService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('note:scheduled'); + } + + @bindThis + public async process(job: Bull.Job): Promise { + this.logger.info(`processing ${job.data.draftId}`); + + try { + await acquireDistributedLock(this.redisForTimelines, `note:scheduled:${job.data.draftId}`, 30 * 1000, 1, 100); + } catch (e) { + this.logger.warn(`draft=${job.data.draftId} is already being processed`); + return 'ok'; + } + + const draft = await this.scheduledNotesRepository.findOne({ + where: { id: job.data.draftId, reason: IsNull() }, + relations: ['user'], + }); + + if (draft == null) { + this.logger.warn(`draft not found: ${job.data.draftId}`); + return 'ok'; + } + + if (!draft.user || draft.user.isSuspended) { + this.logger.warn(`user is suspended: ${draft.userId}`); + await this.scheduledNotesRepository.delete({ id: draft.id }); + return 'ok'; + } + + try { + const note = (await this.noteCreateService.create(draft.user, { + ...draft.draft, + createdAt: new Date(), + scheduledAt: null, + })) as MiNote; + + await this.scheduledNotesRepository.delete({ id: draft.id }); + + this.notificationService.createNotification(draft.userId, "scheduledNotePosted", { + noteId: note.id, + }); + + return 'ok'; + } catch (e) { + if (e instanceof IdentifiableError) { + if ([ + 'e11b3a16-f543-4885-8eb1-66cad131dbfd', + '689ee33f-f97c-479a-ac49-1b9f8140af99', + '9f466dab-c856-48cd-9e65-ff90ff750580', + '85ab9bd7-3a41-4530-959d-f07073900109', + 'd450b8a9-48e4-4dab-ae36-f4db763fda7c', + ].includes(e.id)) { + this.logger.warn(`creating note from draft=${draft.id} failed: ${e.message}`); + + await this.scheduledNotesRepository.update({ id: draft.id }, { + scheduledAt: null, + reason: e.message, + }); + + this.notificationService.createNotification(draft.userId, "scheduledNoteError", { + draftId: draft.id, + }); + + return e.message; + } + } + throw e; + } + } +} diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index 49aedec58478..ca2662155a8c 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -6,6 +6,7 @@ import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiNote } from '@/models/Note.js'; +import type { MiScheduledNote } from '@/models/ScheduledNote.js'; import type { MiUser } from '@/models/User.js'; import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; import type { MiWebhook } from '@/models/Webhook.js'; @@ -116,6 +117,10 @@ export type EndedPollNotificationJobData = { noteId: MiNote['id']; }; +export type ScheduledNoteJobData = { + draftId: MiScheduledNote['id']; +}; + export type WebhookDeliverJobData = { type: string; content: unknown; diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 924eac7a1fb4..33e93a81487f 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -287,6 +287,8 @@ import * as ep___notes_clips from './endpoints/notes/clips.js'; import * as ep___notes_conversation from './endpoints/notes/conversation.js'; import * as ep___notes_create from './endpoints/notes/create.js'; import * as ep___notes_delete from './endpoints/notes/delete.js'; +import * as ep___notes_scheduled_cancel from './endpoints/notes/scheduled/cancel.js'; +import * as ep___notes_scheduled_list from './endpoints/notes/scheduled/list.js'; import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js'; import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js'; import * as ep___notes_featured from './endpoints/notes/featured.js'; @@ -682,6 +684,8 @@ const $notes_clips: Provider = { provide: 'ep:notes/clips', useClass: ep___notes const $notes_conversation: Provider = { provide: 'ep:notes/conversation', useClass: ep___notes_conversation.default }; const $notes_create: Provider = { provide: 'ep:notes/create', useClass: ep___notes_create.default }; const $notes_delete: Provider = { provide: 'ep:notes/delete', useClass: ep___notes_delete.default }; +const $notes_scheduled_cancel: Provider = { provide: 'ep:notes/scheduled/cancel', useClass: ep___notes_scheduled_cancel.default }; +const $notes_scheduled_list: Provider = { provide: 'ep:notes/scheduled/list', useClass: ep___notes_scheduled_list.default }; const $notes_favorites_create: Provider = { provide: 'ep:notes/favorites/create', useClass: ep___notes_favorites_create.default }; const $notes_favorites_delete: Provider = { provide: 'ep:notes/favorites/delete', useClass: ep___notes_favorites_delete.default }; const $notes_featured: Provider = { provide: 'ep:notes/featured', useClass: ep___notes_featured.default }; @@ -1081,6 +1085,8 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $notes_conversation, $notes_create, $notes_delete, + $notes_scheduled_cancel, + $notes_scheduled_list, $notes_favorites_create, $notes_favorites_delete, $notes_featured, @@ -1474,6 +1480,8 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $notes_conversation, $notes_create, $notes_delete, + $notes_scheduled_cancel, + $notes_scheduled_list, $notes_favorites_create, $notes_favorites_delete, $notes_featured, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 987228be66b1..a5f51328c035 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -287,6 +287,8 @@ import * as ep___notes_clips from './endpoints/notes/clips.js'; import * as ep___notes_conversation from './endpoints/notes/conversation.js'; import * as ep___notes_create from './endpoints/notes/create.js'; import * as ep___notes_delete from './endpoints/notes/delete.js'; +import * as ep___notes_scheduled_cancel from './endpoints/notes/scheduled/cancel.js'; +import * as ep___notes_scheduled_list from './endpoints/notes/scheduled/list.js'; import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js'; import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js'; import * as ep___notes_featured from './endpoints/notes/featured.js'; @@ -680,6 +682,8 @@ const eps = [ ['notes/conversation', ep___notes_conversation], ['notes/create', ep___notes_create], ['notes/delete', ep___notes_delete], + ['notes/scheduled/cancel', ep___notes_scheduled_cancel], + ['notes/scheduled/list', ep___notes_scheduled_list], ['notes/favorites/create', ep___notes_favorites_create], ['notes/favorites/delete', ep___notes_favorites_delete], ['notes/featured', ep___notes_featured], diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 963f3bbfdbe2..c90c9daf9675 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -17,6 +17,7 @@ import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteCreateService } from '@/core/NoteCreateService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { DI } from '@/di-symbols.js'; import { isQuote, isRenote } from '@/misc/is-renote.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; @@ -148,6 +149,31 @@ export const meta = { id: '66819f28-9525-389d-4b0a-4974363fbbbf', }, + cannotScheduleToPast: { + message: 'Cannot schedule to the past.', + code: 'CANNOT_SCHEDULE_TO_PAST', + id: 'e577d185-8179-4a17-b47f-6093985558e6', + }, + + cannotScheduleToFarFuture: { + message: 'Cannot schedule to the far future.', + code: 'CANNOT_SCHEDULE_TO_FAR_FUTURE', + id: 'ea102856-e8da-4ae9-a98a-0326821bd177', + }, + + cannotScheduleSameTime: { + message: 'Cannot schedule multiple notes at the same time.', + code: 'CANNOT_SCHEDULE_SAME_TIME', + id: '187a8fab-fd83-4ae6-a46c-0f6f07784634', + }, + + rolePermissionDenied: { + message: 'You are not assigned to a required role.', + code: 'ROLE_PERMISSION_DENIED', + kind: 'permission', + id: '12f1d5d2-f7ec-4d7c-b608-e873f4b20327', + status: 403, + }, }, } as const; @@ -207,6 +233,7 @@ export const paramDef = { }, required: ['choices'], }, + scheduledAt: { type: 'integer', nullable: true }, noCreatedNote: { type: 'boolean', default: false }, }, // (re)note with text, files and poll are optional @@ -263,6 +290,7 @@ export default class extends Endpoint { // eslint- private loggerService: LoggerService, private noteEntityService: NoteEntityService, private noteCreateService: NoteCreateService, + private notificationService: NotificationService, ) { super(meta, paramDef, async (ps, me, _token, _file, _cleanup, ip, headers) => { const logger = this.loggerService.getLogger('api:notes:create'); @@ -318,7 +346,7 @@ export default class extends Endpoint { // eslint- let renote: MiNote | null = null; if (ps.renoteId != null) { // Fetch renote to note - renote = await this.notesRepository.findOneBy({ id: ps.renoteId }); + renote = await this.notesRepository.findOne({ where: { id: ps.renoteId }, relations: ['user'] }); if (renote == null) { logger.error('No such renote target.', { renoteId: ps.renoteId }); @@ -371,7 +399,7 @@ export default class extends Endpoint { // eslint- let reply: MiNote | null = null; if (ps.replyId != null) { // Fetch reply - reply = await this.notesRepository.findOneBy({ id: ps.replyId }); + reply = await this.notesRepository.findOne({ where: { id: ps.replyId }, relations: ['user'] }); if (reply == null) { logger.error('No such reply target.', { replyId: ps.replyId }); @@ -384,11 +412,8 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.cannotReplyToInvisibleNote); } else if (reply.visibility === 'specified' && ps.visibility !== 'specified') { throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility); - } else if ( me.isBot ) { - const replayuser = await this.usersRepository.findOneBy({ id: reply.userId }); - if (replayuser?.isBot) { - throw new ApiError(meta.errors.replyingToAnotherBot); - } + } else if (me.isBot && reply.user!.isBot) { + throw new ApiError(meta.errors.replyingToAnotherBot); } // Check blocking @@ -427,10 +452,28 @@ export default class extends Endpoint { // eslint- } } + let scheduledAt: Date | null = null; + if (ps.scheduledAt) { + const now = new Date(); + scheduledAt = new Date(ps.scheduledAt); + scheduledAt.setMilliseconds(0); + + if (scheduledAt < now) { + logger.error('Cannot schedule to the past.', { scheduledAt }); + throw new ApiError(meta.errors.cannotScheduleToPast); + } + + if (scheduledAt.getTime() - now.getTime() > ms('1year')) { + logger.error('Cannot schedule to the far future.', { scheduledAt }); + throw new ApiError(meta.errors.cannotScheduleToFarFuture); + } + } + // 投稿を作成 try { const note = await this.noteCreateService.create(me, { createdAt: new Date(), + scheduledAt: ps.scheduledAt ? scheduledAt : null, files: files, poll: ps.poll ? { choices: ps.poll.choices, @@ -454,10 +497,18 @@ export default class extends Endpoint { // eslint- // 1分間、リクエストの処理結果を記録 await this.redisForTimelines.set(`note:idempotent:${me.id}:${hash}`, note.id, 'EX', 60); - logger.info('Successfully created a note.', { noteId: note.id }); - if (ps.noCreatedNote) return; + if (!scheduledAt) { + logger.info('Successfully created a note.', { noteId: note.id }); + } else { + this.notificationService.createNotification(me.id, "noteScheduled", { + draftId: note.id, + }); + logger.info('Successfully scheduled a note.', { draftId: note.id }); + } + + if (ps.noCreatedNote || scheduledAt) return; else return { - createdNote: await this.noteEntityService.pack(note, me), + createdNote: await this.noteEntityService.pack(note as MiNote, me), }; } catch (err) { // エラーが発生した場合、まだ処理中として記録されている場合はリクエストの処理結果を削除 @@ -468,6 +519,8 @@ export default class extends Endpoint { // eslint- if (err instanceof IdentifiableError) { if (err.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') throw new ApiError(meta.errors.containsProhibitedWords); if (err.id === '9f466dab-c856-48cd-9e65-ff90ff750580') throw new ApiError(meta.errors.containsTooManyMentions); + if (err.id === '7cc42034-f7ab-4f7c-87b4-e00854479080') throw new ApiError(meta.errors.rolePermissionDenied); + if (err.id === '5ea8e4f5-9d64-4e6c-92b8-9e2b5a4756bc') throw new ApiError(meta.errors.cannotScheduleSameTime); } throw err; diff --git a/packages/backend/src/server/api/endpoints/notes/scheduled/cancel.ts b/packages/backend/src/server/api/endpoints/notes/scheduled/cancel.ts new file mode 100644 index 000000000000..b3627521e296 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/scheduled/cancel.ts @@ -0,0 +1,57 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import type { ScheduledNotesRepository } from '@/models/_.js'; +import { QueueService } from '@/core/QueueService.js'; +import { ApiError } from '@/server/api/error.js'; +import ms from 'ms'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + requireRolePolicy: 'canCreateContent', + + prohibitMoved: true, + + limit: { + duration: ms('1hour'), + max: 300, + }, + + kind: 'write:notes', + + errors: { + noSuchDraft: { + message: 'No such draft', + code: 'NO_SUCH_DRAFT', + id: '91c2ad21-fb45-4f2a-ba4c-ea749b262947', + } + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + draftId: { type: 'string', format: 'misskey:id' }, + }, + required: ['draftId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.scheduledNotesRepository) + private scheduledNotesRepository: ScheduledNotesRepository, + + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + const draft = await this.scheduledNotesRepository.findOneBy({ id: ps.draftId, userId: me.id }); + if (!draft) throw new ApiError(meta.errors.noSuchDraft); + + await this.queueService.systemQueue.remove(`scheduledNote:${draft.id}`); + await this.scheduledNotesRepository.delete({ id: draft.id }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/scheduled/list.ts b/packages/backend/src/server/api/endpoints/notes/scheduled/list.ts new file mode 100644 index 000000000000..c27a7b9a78e0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/scheduled/list.ts @@ -0,0 +1,49 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import type { ScheduledNotesRepository } from '@/models/_.js'; +import { ScheduledNoteEntityService } from '@/core/entities/ScheduledNoteEntityService.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + requireRolePolicy: 'canScheduleNote', + + kind: 'write:notes', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'NoteDraft', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + offset: { type: 'integer', default: 0 }, + }, +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.scheduledNotesRepository) + private scheduledNotesRepository: ScheduledNotesRepository, + + private scheduledNoteEntityService: ScheduledNoteEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.scheduledNotesRepository.createQueryBuilder('draft').where('draft.userId = :userId', { userId: me.id }); + const drafts = await query.orderBy('draft.scheduledAt', 'ASC', 'NULLS FIRST').offset(ps.offset).limit(ps.limit).getMany(); + + return await this.scheduledNoteEntityService.packMany(drafts, me); + }); + } +} diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index e125b074ffc1..945eb27b54c9 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -3,6 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import type { MiDriveFile } from '@/models/DriveFile.js'; +import type { IPoll } from '@/models/Poll.js'; +import type { MiChannel } from '@/models/Channel.js'; +import type { MiApp } from '@/models/App.js'; +import type { MiUser } from '@/models/User.js'; +import type { MiNote } from '@/models/Note.js'; + /** * note - 通知オンにしているユーザーが投稿した * follow - フォローされた @@ -16,6 +23,9 @@ * followRequestAccepted - 自分の送ったフォローリクエストが承認された * roleAssigned - ロールが付与された * achievementEarned - 実績を獲得 + * noteScheduled - 予約投稿が予約された + * scheduledNotePosted - 予約投稿が投稿された + * scheduledNoteError - 予約投稿がエラーになった * app - アプリ通知 * test - テスト通知(サーバー側) */ @@ -32,6 +42,9 @@ export const notificationTypes = [ 'followRequestAccepted', 'roleAssigned', 'achievementEarned', + 'noteScheduled', + 'scheduledNotePosted', + 'scheduledNoteError', 'app', 'test', ] as const; @@ -338,6 +351,36 @@ export type ModerationLogPayloads = { } }; +export type MinimumUser = { + id: MiUser['id']; + host: MiUser['host']; + username: MiUser['username']; + uri: MiUser['uri']; +}; + +export type NoteCreateOption = { + createdAt?: Date | null; + scheduledAt?: Date | null; + name?: string | null; + text?: string | null; + reply?: MiNote | null; + renote?: MiNote | null; + files?: MiDriveFile[] | null; + poll?: IPoll | null; + localOnly?: boolean | null; + reactionAcceptance?: MiNote['reactionAcceptance']; + cw?: string | null; + visibility?: string; + visibleUsers?: MinimumUser[] | null; + channel?: MiChannel | null; + apMentions?: MinimumUser[] | null; + apHashtags?: string[] | null; + apEmojis?: string[] | null; + uri?: string | null; + url?: string | null; + app?: MiApp | null; +}; + export type Serialized = { [K in keyof T]: T[K] extends Date diff --git a/packages/backend/test/unit/chart.ts b/packages/backend/test/unit/chart.ts index d54f5f431336..ccdb9a33c7b8 100644 --- a/packages/backend/test/unit/chart.ts +++ b/packages/backend/test/unit/chart.ts @@ -9,6 +9,7 @@ import * as assert from 'assert'; import { jest } from '@jest/globals'; import * as lolex from '@sinonjs/fake-timers'; import { DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import TestChart from '@/core/chart/charts/test.js'; import TestGroupedChart from '@/core/chart/charts/test-grouped.js'; import TestUniqueChart from '@/core/chart/charts/test-unique.js'; @@ -18,16 +19,16 @@ import { entity as TestGroupedChartEntity } from '@/core/chart/charts/entities/t import { entity as TestUniqueChartEntity } from '@/core/chart/charts/entities/test-unique.js'; import { entity as TestIntersectionChartEntity } from '@/core/chart/charts/entities/test-intersection.js'; import { loadConfig } from '@/config.js'; -import type { AppLockService } from '@/core/AppLockService.js'; import { coreLogger } from '@/logger.js'; describe('Chart', () => { const config = loadConfig(); - const appLockService = { - getChartInsertLock: () => () => Promise.resolve(() => {}), - } as unknown as jest.Mocked; let db: DataSource | undefined; + let redisForTimelines = { + set: () => Promise.resolve('OK'), + get: () => Promise.resolve(null), + } as unknown as jest.Mocked; let testChart: TestChart; let testGroupedChart: TestGroupedChart; @@ -64,10 +65,10 @@ describe('Chart', () => { await db.initialize(); const logger = coreLogger.createSubLogger('chart'); // TODO: モックにする - testChart = new TestChart(db, appLockService, logger); - testGroupedChart = new TestGroupedChart(db, appLockService, logger); - testUniqueChart = new TestUniqueChart(db, appLockService, logger); - testIntersectionChart = new TestIntersectionChart(db, appLockService, logger); + testChart = new TestChart(db, redisForTimelines, logger); + testGroupedChart = new TestGroupedChart(db, redisForTimelines, logger); + testUniqueChart = new TestUniqueChart(db, redisForTimelines, logger); + testIntersectionChart = new TestIntersectionChart(db, redisForTimelines, logger); clock = lolex.install({ now: new Date(Date.UTC(2000, 0, 1, 0, 0, 0)), diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 6f54dfa1c656..96547fca6010 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -27,11 +27,11 @@ "@rollup/plugin-typescript": "12.1.2", "@rollup/pluginutils": "5.1.4", "@syuilo/aiscript": "0.19.0", - "@tabler/icons-webfont": "3.26.0", + "@tabler/icons-webfont": "3.28.1", "@twemoji/parser": "15.1.1", "@vitejs/plugin-vue": "5.2.1", "@vue/compiler-sfc": "3.5.13", - "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.13", + "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15", "astring": "1.9.0", "broadcast-channel": "7.0.0", "buraha": "0.0.1", @@ -41,7 +41,7 @@ "chartjs-chart-matrix": "2.0.1", "chartjs-plugin-gradient": "0.6.1", "chartjs-plugin-zoom": "2.2.0", - "chromatic": "11.20.2", + "chromatic": "11.24.0", "compare-versions": "6.1.1", "cropperjs": "2.0.0-rc.0", "date-fns": "4.1.0", @@ -59,21 +59,21 @@ "misskey-reversi": "workspace:*", "photoswipe": "5.4.4", "punycode.js": "2.3.1", - "rollup": "4.29.1", + "rollup": "4.30.1", "sanitize-html": "2.14.0", - "sass": "1.83.0", - "shiki": "1.24.4", + "sass": "1.83.4", + "shiki": "1.27.2", "strict-event-emitter-types": "2.0.0", "textarea-caret": "3.1.0", - "three": "0.171.0", + "three": "0.172.0", "throttle-debounce": "5.0.2", "tinycolor2": "1.6.0", "tsc-alias": "1.8.10", "tsconfig-paths": "4.2.0", - "typescript": "5.7.2", - "uuid": "11.0.3", + "typescript": "5.7.3", + "uuid": "11.0.5", "v-code-diff": "1.13.1", - "vite": "6.0.6", + "vite": "6.0.7", "vue": "3.5.13", "vue-gtag": "2.0.1", "vuedraggable": "next", @@ -82,33 +82,33 @@ "devDependencies": { "@misskey-dev/eslint-plugin": "1.0.0", "@misskey-dev/summaly": "github:MisskeyIO/summaly#5.1.3", - "@storybook/addon-actions": "8.4.7", - "@storybook/addon-essentials": "8.4.7", - "@storybook/addon-interactions": "8.4.7", - "@storybook/addon-links": "8.4.7", - "@storybook/addon-mdx-gfm": "8.4.7", - "@storybook/addon-storysource": "8.4.7", - "@storybook/blocks": "8.4.7", - "@storybook/components": "8.4.7", - "@storybook/core-events": "8.4.7", - "@storybook/manager-api": "8.4.7", - "@storybook/preview-api": "8.4.7", - "@storybook/react": "8.4.7", - "@storybook/react-vite": "8.4.7", - "@storybook/test": "8.4.7", - "@storybook/theming": "8.4.7", - "@storybook/types": "8.4.7", - "@storybook/vue3": "8.4.7", - "@storybook/vue3-vite": "8.4.7", + "@storybook/addon-actions": "8.5.0", + "@storybook/addon-essentials": "8.5.0", + "@storybook/addon-interactions": "8.5.0", + "@storybook/addon-links": "8.5.0", + "@storybook/addon-mdx-gfm": "8.5.0", + "@storybook/addon-storysource": "8.5.0", + "@storybook/blocks": "8.5.0", + "@storybook/components": "8.5.0", + "@storybook/core-events": "8.5.0", + "@storybook/manager-api": "8.5.0", + "@storybook/preview-api": "8.5.0", + "@storybook/react": "8.5.0", + "@storybook/react-vite": "8.5.0", + "@storybook/test": "8.5.0", + "@storybook/theming": "8.5.0", + "@storybook/types": "8.5.0", + "@storybook/vue3": "8.5.0", + "@storybook/vue3-vite": "8.5.0", "@testing-library/vue": "8.1.0", "@types/escape-regexp": "0.0.3", "@types/estree": "1.0.6", "@types/matter-js": "0.19.8", "@types/micromatch": "4.0.9", - "@types/node": "22.10.2", + "@types/node": "22.10.7", "@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/sanitize-html": "2.13.0", - "@types/three": "0.171.0", + "@types/three": "0.172.0", "@types/throttle-debounce": "5.0.2", "@types/tinycolor2": "1.4.6", "@types/ws": "8.5.13", @@ -122,8 +122,8 @@ "eslint": "8.57.1", "eslint-plugin-import": "2.31.0", "eslint-plugin-vue": "9.32.0", - "fast-glob": "3.3.2", - "happy-dom": "16.0.1", + "fast-glob": "3.3.3", + "happy-dom": "16.6.0", "intersection-observer": "0.12.2", "micromatch": "4.0.8", "msw": "2.7.0", @@ -132,8 +132,8 @@ "prettier": "3.4.2", "react": "19.0.0", "react-dom": "19.0.0", - "start-server-and-test": "2.0.9", - "storybook": "8.4.7", + "start-server-and-test": "2.0.10", + "storybook": "8.5.0", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", "vite-plugin-turbosnap": "1.0.3", "vitest": "2.1.8", diff --git a/packages/frontend/src/components/MkDraftsDialog.vue b/packages/frontend/src/components/MkDraftsDialog.vue index 755bb2cf12be..289707cd2e4e 100644 --- a/packages/frontend/src/components/MkDraftsDialog.vue +++ b/packages/frontend/src/components/MkDraftsDialog.vue @@ -10,14 +10,18 @@ -
+ + + + +
{{ i18n.ts.nothing }}
-
+
@@ -35,7 +39,13 @@
- +
+
+ + +
+ +
@@ -56,24 +66,94 @@
-
+ + + + diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 54e7e161faff..6a67a98a1d0c 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -569,13 +569,8 @@ function emitUpdReaction(emoji: string, delta: number) { overflow: clip; contain: content; - // これらの指定はパフォーマンス向上には有効だが、ノートの高さは一定でないため、 - // 下の方までスクロールすると上のノートの高さがここで決め打ちされたものに変化し、表示しているノートの位置が変わってしまう - // ノートがマウントされたときに自身の高さを取得し contain-intrinsic-size を設定しなおせばほぼ解決できそうだが、 - // 今度はその処理自体がパフォーマンス低下の原因にならないか懸念される。また、被リアクションでも高さは変化するため、やはり多少のズレは生じる - // 一度レンダリングされた要素はブラウザがよしなにサイズを覚えておいてくれるような実装になるまで待った方が良さそう(なるのか?) - //content-visibility: auto; - //contain-intrinsic-size: 0 128px; + content-visibility: auto; + contain-intrinsic-size: auto none auto 128px; &:focus-visible { outline: none; diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index 1450a3022db1..5bf5188c2778 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
@@ -25,6 +25,9 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.t_quote]: notification.type === 'quote', [$style.t_pollEnded]: notification.type === 'pollEnded', [$style.t_achievementEarned]: notification.type === 'achievementEarned', + [$style.t_noteScheduled]: notification.type === 'noteScheduled', + [$style.t_scheduledNotePosted]: notification.type === 'scheduledNotePosted', + [$style.t_scheduledNoteError]: notification.type === 'scheduledNoteError', [$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null, }]" > @@ -37,6 +40,9 @@ SPDX-License-Identifier: AGPL-3.0-only + + +