diff --git a/Cargo.lock b/Cargo.lock index 4eefdd76b4..0d8d7f8519 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 3 [[package]] name = "addr2line" -version = "0.22.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" dependencies = [ "gimli", ] @@ -239,9 +239,9 @@ dependencies = [ [[package]] name = "backtrace" -version = "0.3.72" +version = "0.3.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17c6a35df3749d2e8bb1b7b21a976d82b15548788d2735b9d82f329268f71a11" +checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d" dependencies = [ "addr2line", "cc", @@ -928,6 +928,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "finl_unicode" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdc7a0362c9f4444381a9e697c79d435fe65b52a37466fc2c1184cee9edc6" + [[package]] name = "flate2" version = "1.0.30" @@ -1117,9 +1123,9 @@ dependencies = [ [[package]] name = "gimli" -version = "0.29.0" +version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" [[package]] name = "group" @@ -1539,9 +1545,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.18" +version = "1.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c15da26e5af7e25c90b37a2d75cdbf940cf4a55316de9d84c679c9b8bfabf82e" +checksum = "5e143b5e666b2695d28f6bca6497720813f699c9602dd7f5cac91008b8ada7f9" dependencies = [ "cc", "libc", @@ -1885,9 +1891,9 @@ dependencies = [ [[package]] name = "object" -version = "0.35.0" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8ec7ab813848ba4522158d5517a6093db1ded27575b070f4177b8d12b41db5e" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" dependencies = [ "memchr", ] @@ -2023,9 +2029,9 @@ checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae" [[package]] name = "parking_lot" -version = "0.12.3" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +checksum = "7e4af0ca4f6caed20e900d564c242b8e5d4903fdacf31d3daf527b66fe6f42fb" dependencies = [ "lock_api", "parking_lot_core", @@ -3218,13 +3224,13 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "stringprep" -version = "0.1.5" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +checksum = "bb41d74e231a107a1b4ee36bd1214b11285b77768d2e3824aedafa988fd36ee6" dependencies = [ + "finl_unicode", "unicode-bidi", "unicode-normalization", - "unicode-properties", ] [[package]] @@ -3585,12 +3591,6 @@ dependencies = [ "tinyvec", ] -[[package]] -name = "unicode-properties" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4259d9d4425d9f0661581b804cb85fe66a4c631cadd8f490d1c13a35d5d9291" - [[package]] name = "unicode-segmentation" version = "1.11.0" @@ -3982,9 +3982,9 @@ checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" [[package]] name = "winnow" -version = "0.6.9" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86c949fede1d13936a99f14fafd3e76fd642b556dd2ce96287fbe2e0151bfac6" +checksum = "c3c52e9c97a68071b23e836c9380edae937f17b9c4667bd021973efc689f618d" dependencies = [ "memchr", ] @@ -4017,9 +4017,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" [[package]] name = "zune-core" diff --git a/docs/downgrade.sql b/docs/downgrade.sql index e7de398a22..895046087a 100644 --- a/docs/downgrade.sql +++ b/docs/downgrade.sql @@ -1,6 +1,7 @@ BEGIN; DELETE FROM "migrations" WHERE name IN ( + 'RefactorScheduledPosts1716804636187', 'RemoveEnumTypenameSuffix1716462794927', 'CreateScheduledNote1714728200194', 'AddBackTimezone1715351290096', @@ -33,6 +34,38 @@ DELETE FROM "migrations" WHERE name IN ( 'RemoveNativeUtilsMigration1705877093218' ); +-- refactor-scheduled-post +CREATE TABLE "scheduled_note" ( + "id" character varying(32) NOT NULL PRIMARY KEY, + "noteId" character varying(32) NOT NULL, + "userId" character varying(32) NOT NULL, + "scheduledAt" TIMESTAMP WITH TIME ZONE NOT NULL +); +COMMENT ON COLUMN "scheduled_note"."noteId" IS 'The ID of the temporarily created note that corresponds to the schedule.'; +CREATE EXTENSION pgcrypto; +CREATE FUNCTION generate_scheduled_note_id(size int) RETURNS text AS $$ DECLARE + characters text := 'abcdefghijklmnopqrstuvwxyz0123456789'; + bytes bytea := gen_random_bytes(size); + l int := length(characters); + i int := 0; + output text := ''; + BEGIN + WHILE i < size LOOP + output := output || substr(characters, get_byte(bytes, i) % l + 1, 1); + i := i + 1; + END LOOP; + RETURN output; + END; +$$ LANGUAGE plpgsql VOLATILE; +INSERT INTO "scheduled_note" ("id", "noteId", "userId", "scheduledAt") (SELECT generate_scheduled_note_id(16), "id", "userId", "scheduledAt" FROM "note" WHERE "note"."scheduledAt" IS NOT NULL); +DROP EXTENSION pgcrypto; +DROP FUNCTION "generate_scheduled_note_id"; +CREATE INDEX "IDX_noteId_ScheduledNote" ON "scheduled_note" ("noteId"); +CREATE INDEX "IDX_userId_ScheduledNote" ON "scheduled_note" ("userId"); +ALTER TABLE "scheduled_note" ADD FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION; +ALTER TABLE "scheduled_note" ADD FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION; +ALTER TABLE "note" DROP COLUMN "scheduledAt"; + -- remove-enum-typename-suffix ALTER TYPE "antenna_src" RENAME TO "antenna_src_enum"; ALTER TYPE "drive_file_usage_hint" RENAME TO "drive_file_usage_hint_enum"; diff --git a/packages/backend-rs/index.d.ts b/packages/backend-rs/index.d.ts index 64ff701fc8..0de1dccf78 100644 --- a/packages/backend-rs/index.d.ts +++ b/packages/backend-rs/index.d.ts @@ -922,6 +922,7 @@ export interface Note { threadId: string | null updatedAt: DateTimeWithTimeZone | null lang: string | null + scheduledAt: DateTimeWithTimeZone | null } export interface NoteEdit { id: string @@ -1081,12 +1082,6 @@ export interface ReplyMuting { muteeId: string muterId: string } -export interface ScheduledNote { - id: string - noteId: string - userId: string - scheduledAt: DateTimeWithTimeZone -} export enum AntennaSrc { All = 'all', Group = 'group', diff --git a/packages/backend-rs/src/model/entity/mod.rs b/packages/backend-rs/src/model/entity/mod.rs index 7f8d16f1ae..ffb21352d2 100644 --- a/packages/backend-rs/src/model/entity/mod.rs +++ b/packages/backend-rs/src/model/entity/mod.rs @@ -53,7 +53,6 @@ pub mod registry_item; pub mod relay; pub mod renote_muting; pub mod reply_muting; -pub mod scheduled_note; pub mod sea_orm_active_enums; pub mod signin; pub mod sw_subscription; diff --git a/packages/backend-rs/src/model/entity/note.rs b/packages/backend-rs/src/model/entity/note.rs index 37cbd54862..4733e85d26 100644 --- a/packages/backend-rs/src/model/entity/note.rs +++ b/packages/backend-rs/src/model/entity/note.rs @@ -68,6 +68,8 @@ pub struct Model { #[sea_orm(column_name = "updatedAt")] pub updated_at: Option<DateTimeWithTimeZone>, pub lang: Option<String>, + #[sea_orm(column_name = "scheduledAt")] + pub scheduled_at: Option<DateTimeWithTimeZone>, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] @@ -124,8 +126,6 @@ pub enum Relation { PromoNote, #[sea_orm(has_many = "super::promo_read::Entity")] PromoRead, - #[sea_orm(has_many = "super::scheduled_note::Entity")] - ScheduledNote, #[sea_orm( belongs_to = "super::user::Entity", from = "Column::UserId", @@ -228,12 +228,6 @@ impl Related<super::promo_read::Entity> for Entity { } } -impl Related<super::scheduled_note::Entity> for Entity { - fn to() -> RelationDef { - Relation::ScheduledNote.def() - } -} - impl Related<super::user::Entity> for Entity { fn to() -> RelationDef { Relation::User.def() diff --git a/packages/backend-rs/src/model/entity/prelude.rs b/packages/backend-rs/src/model/entity/prelude.rs index 9da0c02506..57fff023db 100644 --- a/packages/backend-rs/src/model/entity/prelude.rs +++ b/packages/backend-rs/src/model/entity/prelude.rs @@ -51,7 +51,6 @@ pub use super::registry_item::Entity as RegistryItem; pub use super::relay::Entity as Relay; pub use super::renote_muting::Entity as RenoteMuting; pub use super::reply_muting::Entity as ReplyMuting; -pub use super::scheduled_note::Entity as ScheduledNote; pub use super::signin::Entity as Signin; pub use super::sw_subscription::Entity as SwSubscription; pub use super::used_username::Entity as UsedUsername; diff --git a/packages/backend-rs/src/model/entity/scheduled_note.rs b/packages/backend-rs/src/model/entity/scheduled_note.rs deleted file mode 100644 index f4c5b0b4c4..0000000000 --- a/packages/backend-rs/src/model/entity/scheduled_note.rs +++ /dev/null @@ -1,55 +0,0 @@ -//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.15 - -use sea_orm::entity::prelude::*; - -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -#[sea_orm(table_name = "scheduled_note")] -#[cfg_attr( - feature = "napi", - napi_derive::napi(object, js_name = "ScheduledNote", use_nullable = true) -)] -pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] - pub id: String, - #[sea_orm(column_name = "noteId")] - pub note_id: String, - #[sea_orm(column_name = "userId")] - pub user_id: String, - #[sea_orm(column_name = "scheduledAt")] - pub scheduled_at: DateTimeWithTimeZone, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation { - #[sea_orm( - belongs_to = "super::note::Entity", - from = "Column::NoteId", - to = "super::note::Column::Id", - on_update = "NoAction", - on_delete = "Cascade" - )] - Note, - #[sea_orm( - belongs_to = "super::user::Entity", - from = "Column::UserId", - to = "super::user::Column::Id", - on_update = "NoAction", - on_delete = "Cascade" - )] - User, -} - -impl Related<super::note::Entity> for Entity { - fn to() -> RelationDef { - Relation::Note.def() - } -} - -impl Related<super::user::Entity> for Entity { - fn to() -> RelationDef { - Relation::User.def() - } -} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/packages/backend-rs/src/model/entity/user.rs b/packages/backend-rs/src/model/entity/user.rs index 974ba2890c..309410b0b6 100644 --- a/packages/backend-rs/src/model/entity/user.rs +++ b/packages/backend-rs/src/model/entity/user.rs @@ -153,8 +153,6 @@ pub enum Relation { PromoRead, #[sea_orm(has_many = "super::registry_item::Entity")] RegistryItem, - #[sea_orm(has_many = "super::scheduled_note::Entity")] - ScheduledNote, #[sea_orm(has_many = "super::signin::Entity")] Signin, #[sea_orm(has_many = "super::sw_subscription::Entity")] @@ -347,12 +345,6 @@ impl Related<super::registry_item::Entity> for Entity { } } -impl Related<super::scheduled_note::Entity> for Entity { - fn to() -> RelationDef { - Relation::ScheduledNote.def() - } -} - impl Related<super::signin::Entity> for Entity { fn to() -> RelationDef { Relation::Signin.def() diff --git a/packages/backend/src/db/postgre.ts b/packages/backend/src/db/postgre.ts index 67954b1033..d1030f5125 100644 --- a/packages/backend/src/db/postgre.ts +++ b/packages/backend/src/db/postgre.ts @@ -74,7 +74,6 @@ import { Webhook } from "@/models/entities/webhook.js"; import { UserIp } from "@/models/entities/user-ip.js"; import { NoteEdit } from "@/models/entities/note-edit.js"; import { NoteFile } from "@/models/entities/note-file.js"; -import { ScheduledNote } from "@/models/entities/scheduled-note.js"; import { entities as charts } from "@/services/chart/entities.js"; import { dbLogger } from "./logger.js"; @@ -183,7 +182,6 @@ export const entities = [ UserPending, Webhook, UserIp, - ScheduledNote, ...charts, ]; diff --git a/packages/backend/src/migration/1716804636187-refactor-scheduled-posts.ts b/packages/backend/src/migration/1716804636187-refactor-scheduled-posts.ts new file mode 100644 index 0000000000..7beaadace2 --- /dev/null +++ b/packages/backend/src/migration/1716804636187-refactor-scheduled-posts.ts @@ -0,0 +1,81 @@ +import type { MigrationInterface, QueryRunner } from "typeorm"; + +export class RefactorScheduledPosts1716804636187 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query( + `ALTER TABLE "note" ADD COLUMN "scheduledAt" timestamp with time zone`, + ); + await queryRunner.query( + `CREATE TEMP TABLE "tmp_scheduled_note" (LIKE "note")`, + ); + await queryRunner.query( + `INSERT INTO "tmp_scheduled_note" (SELECT * FROM "note" WHERE "note"."id" IN (SELECT "noteId" FROM "scheduled_note"))`, + ); + await queryRunner.query( + `UPDATE "tmp_scheduled_note" SET "scheduledAt" = "scheduled_note"."scheduledAt" FROM "scheduled_note" WHERE "tmp_scheduled_note"."id" = "scheduled_note"."noteId"`, + ); + await queryRunner.query( + `DELETE FROM "note" WHERE "note"."id" IN (SELECT "noteId" FROM "scheduled_note")`, + ); + await queryRunner.query( + `INSERT INTO "note" SELECT * FROM "tmp_scheduled_note"`, + ); + await queryRunner.query(`DROP TABLE "scheduled_note"`); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query( + `CREATE TABLE "scheduled_note" ( + "id" character varying(32) NOT NULL PRIMARY KEY, + "noteId" character varying(32) NOT NULL, + "userId" character varying(32) NOT NULL, + "scheduledAt" TIMESTAMP WITH TIME ZONE NOT NULL + )`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "scheduled_note"."noteId" IS 'The ID of the temporarily created note that corresponds to the schedule.'`, + ); + // temp function to populate "scheduled_note"."id" with random values (as it's unused) + await queryRunner.query("CREATE EXTENSION pgcrypto"); + await queryRunner.query(` + CREATE FUNCTION generate_scheduled_note_id(size int) RETURNS text AS $$ DECLARE + characters text := 'abcdefghijklmnopqrstuvwxyz0123456789'; + bytes bytea := gen_random_bytes(size); + l int := length(characters); + i int := 0; + output text := ''; + BEGIN + WHILE i < size LOOP + output := output || substr(characters, get_byte(bytes, i) % l + 1, 1); + i := i + 1; + END LOOP; + RETURN output; + END; + $$ LANGUAGE plpgsql VOLATILE; + `); + await queryRunner.query( + `INSERT INTO "scheduled_note" ("id", "noteId", "userId", "scheduledAt") (SELECT generate_scheduled_note_id(16), "id", "userId", "scheduledAt" FROM "note" WHERE "note"."scheduledAt" IS NOT NULL)`, + ); + await queryRunner.query("DROP EXTENSION pgcrypto"); + await queryRunner.query(`DROP FUNCTION "generate_scheduled_note_id"`); + await queryRunner.query( + `CREATE INDEX "IDX_noteId_ScheduledNote" ON "scheduled_note" ("noteId")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_userId_ScheduledNote" ON "scheduled_note" ("userId")`, + ); + await queryRunner.query(` + ALTER TABLE "scheduled_note" + ADD FOREIGN KEY ("noteId") REFERENCES "note"("id") + ON DELETE CASCADE + ON UPDATE NO ACTION + `); + await queryRunner.query(` + ALTER TABLE "scheduled_note" + ADD FOREIGN KEY ("userId") REFERENCES "user"("id") + ON DELETE CASCADE + ON UPDATE NO ACTION + `); + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "scheduledAt"`); + } +} diff --git a/packages/backend/src/models/entities/note.ts b/packages/backend/src/models/entities/note.ts index 1c9d3570a3..97db90a880 100644 --- a/packages/backend/src/models/entities/note.ts +++ b/packages/backend/src/models/entities/note.ts @@ -31,6 +31,11 @@ export class Note { }) public createdAt: Date; + @Column("timestamp with time zone", { + nullable: true, + }) + public scheduledAt: Date | null; + @Index() @Column({ ...id(), diff --git a/packages/backend/src/models/entities/scheduled-note.ts b/packages/backend/src/models/entities/scheduled-note.ts deleted file mode 100644 index 6c0b1296d8..0000000000 --- a/packages/backend/src/models/entities/scheduled-note.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - Entity, - JoinColumn, - Column, - ManyToOne, - OneToOne, - PrimaryColumn, - Index, - type Relation, -} from "typeorm"; -import { Note } from "./note.js"; -import { id } from "../id.js"; -import { User } from "./user.js"; - -@Entity() -export class ScheduledNote { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column({ - ...id(), - comment: - "The ID of the temporarily created note that corresponds to the schedule.", - }) - public noteId: Note["id"]; - - @Index() - @Column(id()) - public userId: User["id"]; - - @Column("timestamp with time zone") - public scheduledAt: Date; - - //#region Relations - @OneToOne(() => Note, { - onDelete: "CASCADE", - }) - @JoinColumn() - public note: Relation<Note>; - - @ManyToOne(() => User, { - onDelete: "CASCADE", - }) - @JoinColumn() - public user: Relation<User>; - //#endregion -} diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index d6b81ad70e..c578d9d409 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -67,7 +67,6 @@ import { Webhook } from "./entities/webhook.js"; import { UserIp } from "./entities/user-ip.js"; import { NoteFileRepository } from "./repositories/note-file.js"; import { NoteEditRepository } from "./repositories/note-edit.js"; -import { ScheduledNote } from "./entities/scheduled-note.js"; export const Announcements = db.getRepository(Announcement); export const AnnouncementReads = db.getRepository(AnnouncementRead); @@ -136,4 +135,3 @@ export const RegistryItems = db.getRepository(RegistryItem); export const Webhooks = db.getRepository(Webhook); export const Ads = db.getRepository(Ad); export const PasswordResetRequests = db.getRepository(PasswordResetRequest); -export const ScheduledNotes = db.getRepository(ScheduledNote); diff --git a/packages/backend/src/models/repositories/note.ts b/packages/backend/src/models/repositories/note.ts index 7e362faf88..062973a741 100644 --- a/packages/backend/src/models/repositories/note.ts +++ b/packages/backend/src/models/repositories/note.ts @@ -11,7 +11,6 @@ import { Polls, Channels, Notes, - ScheduledNotes, } from "../index.js"; import type { Packed } from "@/misc/schema.js"; import { countReactions, decodeReaction, nyaify } from "backend-rs"; @@ -199,19 +198,17 @@ export const NoteRepository = db.getRepository(Note).extend({ host, ); - let scheduledAt: string | undefined; - if (note.visibility === "specified" && note.visibleUserIds.length === 0) { - scheduledAt = ( - await ScheduledNotes.findOneBy({ - noteId: note.id, - }) - )?.scheduledAt?.toISOString(); - } - const reactionEmoji = await populateEmojis(reactionEmojiNames, host); const packed: Packed<"Note"> = await awaitAll({ id: note.id, createdAt: note.createdAt.toISOString(), + // FIXME: note.scheduledAt should be a `Date` + scheduledAt: + note.scheduledAt == null + ? undefined + : typeof note.scheduledAt === "string" + ? note.scheduledAt + : note.scheduledAt?.toISOString(), userId: note.userId, user: Users.pack(note.user ?? note.userId, me, { detail: false, @@ -241,7 +238,6 @@ export const NoteRepository = db.getRepository(Note).extend({ }, }) : undefined, - scheduledAt, reactions: countReactions(note.reactions), reactionEmojis: reactionEmoji, emojis: noteEmoji, diff --git a/packages/backend/src/queue/processors/db/scheduled-note.ts b/packages/backend/src/queue/processors/db/scheduled-note.ts index def37f1306..3d3a711dfd 100644 --- a/packages/backend/src/queue/processors/db/scheduled-note.ts +++ b/packages/backend/src/queue/processors/db/scheduled-note.ts @@ -1,4 +1,4 @@ -import { Users, Notes, ScheduledNotes, DriveFiles } from "@/models/index.js"; +import { Users, Notes, DriveFiles } from "@/models/index.js"; import type { DbUserScheduledNoteData } from "@/queue/types.js"; import { queueLogger } from "../../logger.js"; import type Bull from "bull"; @@ -14,52 +14,82 @@ export async function scheduledNote( ): Promise<void> { logger.info(`Creating: ${job.data.noteId}`); - const user = await Users.findOneBy({ id: job.data.user.id }); + const [user, draftNote] = await Promise.all([ + Users.findOneBy({ id: job.data.user.id }), + Notes.findOneBy({ id: job.data.noteId }), + ]); + if (user == null) { + logger.warn(`User ${job.data.user.id} does not exist, aborting`); done(); return; } - const note = await Notes.findOneBy({ id: job.data.noteId }); - if (note == null) { + if (draftNote == null) { + logger.warn(`Note ${job.data.noteId} does not exist, aborting`); done(); return; } - const files = await DriveFiles.findBy({ id: In(note.fileIds) }); if (user.isSuspended) { - deleteNote(user, note); + logger.info( + `Cancelled due to user ${job.data.user.id} being suspended, aborting`, + ); + await deleteNote(user, draftNote); done(); return; } - await ScheduledNotes.delete({ - noteId: note.id, - userId: user.id, - }); + const [visibleUsers, reply, renote, files] = await Promise.all([ + job.data.option.visibleUserIds + ? Users.findBy({ + id: In(job.data.option.visibleUserIds), + }) + : [], + job.data.option.replyId != null + ? Notes.findOneBy({ id: job.data.option.replyId }) + : undefined, + job.data.option.renoteId != null + ? Notes.findOneBy({ id: job.data.option.renoteId }) + : undefined, + DriveFiles.findBy({ id: In(draftNote.fileIds) }), + ]); - const visibleUsers = job.data.option.visibleUserIds - ? await Users.findBy({ - id: In(job.data.option.visibleUserIds), - }) - : []; + if (job.data.option.replyId != null && reply == null) { + logger.warn( + `Note ${job.data.option.replyId} (reply) does not exist, aborting`, + ); + done(); + return; + } + if (job.data.option.renoteId != null && renote == null) { + logger.warn( + `Note ${job.data.option.renoteId} (renote) does not exist, aborting`, + ); + done(); + return; + } + + // Create scheduled (actual) note await createNote(user, { createdAt: new Date(), + scheduledAt: null, files, poll: job.data.option.poll, - text: note.text || undefined, - lang: note.lang, - reply: note.reply, - renote: note.renote, - cw: note.cw, - localOnly: note.localOnly, + text: draftNote.text || undefined, + lang: draftNote.lang, + reply, + renote, + cw: draftNote.cw, + localOnly: draftNote.localOnly, visibility: job.data.option.visibility, visibleUsers, - channel: note.channel, + channel: draftNote.channel, }); - await deleteNote(user, note); + // Delete temporal (draft) note + await deleteNote(user, draftNote); logger.info("Success"); diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index c0d719a312..2db59a031d 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -62,6 +62,8 @@ export type DbUserScheduledNoteData = { option: { visibility: string; visibleUserIds?: string[] | null; + replyId?: string; + renoteId?: string; poll?: IPoll; }; noteId: Note["id"]; diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index ae5cdc2aab..17f9a7ece9 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -7,7 +7,6 @@ import { Notes, Channels, Blockings, - ScheduledNotes, } from "@/models/index.js"; import type { DriveFile } from "@/models/entities/drive-file.js"; import type { Note } from "@/models/entities/note.js"; @@ -16,7 +15,7 @@ import { config } from "@/config.js"; import { noteVisibilities } from "@/types.js"; import { ApiError } from "@/server/api/error.js"; import define from "@/server/api/define.js"; -import { HOUR, genIdAt } from "backend-rs"; +import { HOUR } from "backend-rs"; import { getNote } from "@/server/api/common/getters.js"; import { langmap } from "firefish-js"; import { createScheduledNoteJob } from "@/queue/index.js"; @@ -95,6 +94,12 @@ export const meta = { code: "ACCOUNT_LOCKED", id: "d390d7e1-8a5e-46ed-b625-06271cafd3d3", }, + + scheduledTimeIsPast: { + message: "The scheduled time is past.", + code: "SCHEDULED_TIME_IS_PAST", + id: "277f91df-8d8e-4647-b4e3-5885fda8978a", + }, }, } as const; @@ -299,26 +304,26 @@ export default define(meta, paramDef, async (ps, user) => { } let delay: number | null = null; - if (ps.scheduledAt) { + if (ps.scheduledAt != null) { delay = ps.scheduledAt - Date.now(); if (delay < 0) { - delay = null; + throw new ApiError(meta.errors.scheduledTimeIsPast); } } - const now = new Date(); - // Create a post const note = await create( user, { - createdAt: now, + createdAt: new Date(), + scheduledAt: delay != null ? new Date(ps.scheduledAt!) : null, files: files, poll: ps.poll ? { choices: ps.poll.choices, multiple: ps.poll.multiple, - expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, + expiresAt: + ps.poll.expiresAt != null ? new Date(ps.poll.expiresAt) : null, } : undefined, text: ps.text || undefined, @@ -344,13 +349,6 @@ export default define(meta, paramDef, async (ps, user) => { false, delay ? async (note) => { - await ScheduledNotes.insert({ - id: genIdAt(now), - noteId: note.id, - userId: user.id, - scheduledAt: new Date(ps.scheduledAt as number), - }); - createScheduledNoteJob( { user: { id: user.id }, @@ -367,6 +365,8 @@ export default define(meta, paramDef, async (ps, user) => { : undefined, visibility: ps.visibility, visibleUserIds: ps.visibleUserIds, + replyId: ps.replyId ?? undefined, + renoteId: ps.renoteId ?? undefined, }, }, delay, diff --git a/packages/backend/src/services/note/create.ts b/packages/backend/src/services/note/create.ts index 8fe44a60ba..b37c007152 100644 --- a/packages/backend/src/services/note/create.ts +++ b/packages/backend/src/services/note/create.ts @@ -133,15 +133,10 @@ class NotificationManager { } } -type MinimumUser = { - id: User["id"]; - host: User["host"]; - username: User["username"]; - uri: User["uri"]; -}; - -type Option = { +type UserLike = Pick<User, "id" | "host" | "username" | "uri">; +type NoteLike = { createdAt?: Date | null; + scheduledAt?: Date | null; name?: string | null; text?: string | null; lang?: string | null; @@ -152,9 +147,9 @@ type Option = { localOnly?: boolean | null; cw?: string | null; visibility?: string; - visibleUsers?: MinimumUser[] | null; + visibleUsers?: UserLike[] | null; channel?: Channel | null; - apMentions?: MinimumUser[] | null; + apMentions?: UserLike[] | null; apHashtags?: string[] | null; apEmojis?: string[] | null; uri?: string | null; @@ -163,16 +158,11 @@ type Option = { }; export default async ( - user: { - id: User["id"]; - username: User["username"]; - host: User["host"]; - isSilenced: User["isSilenced"]; - createdAt: User["createdAt"]; - isBot: User["isBot"]; - inbox?: User["inbox"]; - }, - data: Option, + user: Pick< + User, + "id" | "username" | "host" | "isSilenced" | "createdAt" | "isBot" + > & { inbox?: User["inbox"] }, + data: NoteLike, silent = false, waitToPublish?: (note: Note) => Promise<void>, ) => @@ -181,6 +171,9 @@ export default async ( const dontFederateInitially = data.visibility?.startsWith("hidden") === true; + // Whether this is a scheduled "draft" post (yet to be published) + const isDraft = data.scheduledAt != null; + // If you reply outside the channel, match the scope of the target. // TODO (I think it's a process that could be done on the client side, but it's server side for now.) if ( @@ -208,6 +201,7 @@ export default async ( data.createdAt > now ) data.createdAt = now; + if (data.visibility == null) data.visibility = "public"; if (data.localOnly == null) data.localOnly = false; if (data.channel != null) data.visibility = "public"; @@ -277,13 +271,9 @@ export default async ( data.localOnly = true; } - if (data.text) { - data.text = data.text.trim(); - } else { - data.text = null; - } + data.text = data.text?.trim() ?? null; - if (data.lang) { + if (data.lang != null) { if (!Object.keys(langmap).includes(data.lang.toLowerCase())) throw new Error("invalid param"); data.lang = data.lang.toLowerCase(); @@ -297,10 +287,10 @@ export default async ( // Parse MFM if needed if (!(tags && emojis && mentionedUsers)) { - const tokens = data.text ? mfm.parse(data.text)! : []; - const cwTokens = data.cw ? mfm.parse(data.cw)! : []; + const tokens = data.text ? mfm.parse(data.text) : []; + const cwTokens = data.cw ? mfm.parse(data.cw) : []; const choiceTokens = data.poll?.choices - ? concat(data.poll.choices.map((choice) => mfm.parse(choice)!)) + ? concat(data.poll.choices.map((choice) => mfm.parse(choice))) : []; const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens); @@ -318,16 +308,16 @@ export default async ( .splice(0, 32); if ( - data.reply && + data.reply != null && user.id !== data.reply.userId && - !mentionedUsers.some((u) => u.id === data.reply!.userId) + !mentionedUsers.some((u) => u.id === data.reply?.userId) ) { mentionedUsers.push( - await Users.findOneByOrFail({ id: data.reply!.userId }), + await Users.findOneByOrFail({ id: data.reply.userId }), ); } - if (data.visibility === "specified") { + if (!isDraft && data.visibility === "specified") { if (data.visibleUsers == null) throw new Error("invalid param"); for (const u of data.visibleUsers) { @@ -338,10 +328,10 @@ export default async ( if ( data.reply && - !data.visibleUsers.some((x) => x.id === data.reply!.userId) + !data.visibleUsers.some((x) => x.id === data.reply?.userId) ) { data.visibleUsers.push( - await Users.findOneByOrFail({ id: data.reply!.userId }), + await Users.findOneByOrFail({ id: data.reply?.userId }), ); } } @@ -365,314 +355,321 @@ export default async ( }); } - // ハッシュタグ更新 - if (data.visibility === "public" || data.visibility === "home") { - updateHashtags(user, tags); - } + if (!isDraft) { + // ハッシュタグ更新 + if (data.visibility === "public" || data.visibility === "home") { + updateHashtags(user, tags); + } - // Increment notes count (user) - incNotesCountOfUser(user); + // Increment notes count (user) + incNotesCountOfUser(user); - // Word mutes & antenna - const thisNoteIsMutedBy: string[] = []; + // Word mutes & antenna + const thisNoteIsMutedBy: string[] = []; - await hardMutesCache - .fetch(null, () => - UserProfiles.find({ - where: { - enableWordMute: true, - }, - select: ["userId", "mutedWords", "mutedPatterns"], - }), - ) - .then(async (us) => { - for (const u of us) { - if (u.userId === user.id) return; - await checkWordMute(note, u.mutedWords, u.mutedPatterns).then( - (shouldMute: boolean) => { - if (shouldMute) { - thisNoteIsMutedBy.push(u.userId); - MutedNotes.insert({ - id: genId(), - userId: u.userId, - noteId: note.id, - reason: "word", - }); - } + await hardMutesCache + .fetch(null, () => + UserProfiles.find({ + where: { + enableWordMute: true, }, - ); - } - }); + select: ["userId", "mutedWords", "mutedPatterns"], + }), + ) + .then(async (us) => { + for (const u of us) { + if (u.userId === user.id) return; + await checkWordMute(note, u.mutedWords, u.mutedPatterns).then( + (shouldMute: boolean) => { + if (shouldMute) { + thisNoteIsMutedBy.push(u.userId); + MutedNotes.insert({ + id: genId(), + userId: u.userId, + noteId: note.id, + reason: "word", + }); + } + }, + ); + } + }); - // type errors will be resolved by https://github.com/napi-rs/napi-rs/pull/2054 - const _note = toRustObject(note); - if (note.renoteId == null || isQuote(_note)) { - await updateAntennasOnNewNote(_note, user, thisNoteIsMutedBy); - } + // type errors will be resolved by https://github.com/napi-rs/napi-rs/pull/2054 + const _note = toRustObject(note); + if (note.renoteId == null || isQuote(_note)) { + await updateAntennasOnNewNote(_note, user, thisNoteIsMutedBy); + } - // Channel - if (note.channelId) { - ChannelFollowings.findBy({ followeeId: note.channelId }).then( - (followings) => { - for (const following of followings) { - insertNoteUnread(following.followerId, note, { - isSpecified: false, + // Channel + if (note.channelId != null) { + ChannelFollowings.findBy({ followeeId: note.channelId }).then( + (followings) => { + for (const following of followings) { + insertNoteUnread(following.followerId, note, { + isSpecified: false, + isMentioned: false, + }); + } + }, + ); + } + + if (data.reply) { + saveReply(data.reply, note); + } + + // この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき + if ( + data.renote && + !user.isBot && + (await countSameRenotes(user.id, data.renote.id, note.id)) === 0 + ) { + incRenoteCount(data.renote); + } + + if (data.poll?.expiresAt) { + const delay = data.poll.expiresAt.getTime() - Date.now(); + endedPollNotificationQueue.add( + { + noteId: note.id, + }, + { + delay, + removeOnComplete: true, + }, + ); + } + + if (!silent) { + if (Users.isLocalUser(user)) activeUsersChart.write(user); + + // 未読通知を作成 + if (data.visibility === "specified") { + if (data.visibleUsers == null) throw new Error("invalid param"); + + for (const u of data.visibleUsers) { + // ローカルユーザーのみ + if (!Users.isLocalUser(u)) continue; + + insertNoteUnread(u.id, note, { + isSpecified: true, isMentioned: false, }); } - }, - ); - } - - if (data.reply) { - saveReply(data.reply, note); - } - - // この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき - if ( - data.renote && - !user.isBot && - (await countSameRenotes(user.id, data.renote.id, note.id)) === 0 - ) { - incRenoteCount(data.renote); - } - - if (data.poll?.expiresAt) { - const delay = data.poll.expiresAt.getTime() - Date.now(); - endedPollNotificationQueue.add( - { - noteId: note.id, - }, - { - delay, - removeOnComplete: true, - }, - ); - } - - if (!silent) { - if (Users.isLocalUser(user)) activeUsersChart.write(user); - - // 未読通知を作成 - if (data.visibility === "specified") { - if (data.visibleUsers == null) throw new Error("invalid param"); - - for (const u of data.visibleUsers) { - // ローカルユーザーのみ - if (!Users.isLocalUser(u)) continue; - - insertNoteUnread(u.id, note, { - isSpecified: true, - isMentioned: false, - }); - } - } else { - for (const u of mentionedUsers) { - // ローカルユーザーのみ - if (!Users.isLocalUser(u)) continue; - - insertNoteUnread(u.id, note, { - isSpecified: false, - isMentioned: true, - }); - } - } - - if (!dontFederateInitially) { - let publishKey: string; - let noteToPublish: Note; - const relays = await getCachedRelays(); - - // Some relays (e.g., aode-relay) deliver posts by boosting them as - // Announce activities. In that case, user is the relay's actor. - const boostedByRelay = - !!user.inbox && - relays.map((relay) => relay.inbox).includes(user.inbox); - - if (boostedByRelay && data.renote && data.renote.userHost) { - publishKey = `publishedNote:${data.renote.id}`; - noteToPublish = data.renote; } else { - publishKey = `publishedNote:${note.id}`; - noteToPublish = note; - } + for (const u of mentionedUsers) { + // ローカルユーザーのみ + if (!Users.isLocalUser(u)) continue; - const lock = new Mutex(redisClient, "publishedNote"); - await lock.acquire(); - try { - const published = (await redisClient.get(publishKey)) != null; - if (!published) { - await redisClient.set(publishKey, "done", "EX", 30); - if (noteToPublish.renoteId) { - // Prevents other threads from publishing the boosting post - await redisClient.set( - `publishedNote:${noteToPublish.renoteId}`, - "done", - "EX", - 30, - ); - } - publishNotesStream(noteToPublish); - } - } finally { - await lock.release(); - } - } - if (note.replyId != null) { - // Only provide the reply note id here as the recipient may not be authorized to see the note. - publishNoteStream(note.replyId, "replied", { - id: note.id, - }); - } - - const webhooks = await getActiveWebhooks().then((webhooks) => - webhooks.filter((x) => x.userId === user.id && x.on.includes("note")), - ); - - for (const webhook of webhooks) { - webhookDeliver(webhook, "note", { - note: await Notes.pack(note, user), - }); - } - - const nm = new NotificationManager(user, note); - const nmRelatedPromises = []; - - await createMentionedEvents(mentionedUsers, note, nm); - - // If has in reply to note - if (data.reply) { - // Fetch watchers - nmRelatedPromises.push(notifyToWatchersOfReplyee(data.reply, user, nm)); - - // 通知 - if (data.reply.userHost === null) { - const threadMuted = await NoteThreadMutings.findOneBy({ - userId: data.reply.userId, - threadId: data.reply.threadId || data.reply.id, - }); - - if (!threadMuted) { - nm.push(data.reply.userId, "reply"); - - const packedReply = await Notes.pack(note, { - id: data.reply.userId, + insertNoteUnread(u.id, note, { + isSpecified: false, + isMentioned: true, }); - publishMainStream(data.reply.userId, "reply", packedReply); + } + } + if (note.replyId != null) { + // Only provide the reply note id here as the recipient may not be authorized to see the note. + publishNoteStream(note.replyId, "replied", { + id: note.id, + }); + } + + const webhooks = await getActiveWebhooks().then((webhooks) => + webhooks.filter((x) => x.userId === user.id && x.on.includes("note")), + ); + + for (const webhook of webhooks) { + webhookDeliver(webhook, "note", { + note: await Notes.pack(note, user), + }); + } + + const nm = new NotificationManager(user, note); + const nmRelatedPromises = []; + + await createMentionedEvents(mentionedUsers, note, nm); + + // If has in reply to note + if (data.reply != null) { + // Fetch watchers + nmRelatedPromises.push( + notifyToWatchersOfReplyee(data.reply, user, nm), + ); + + // 通知 + if (data.reply.userHost === null) { + const threadMuted = await NoteThreadMutings.findOneBy({ + userId: data.reply.userId, + threadId: data.reply.threadId || data.reply.id, + }); + + if (!threadMuted) { + nm.push(data.reply.userId, "reply"); + + const packedReply = await Notes.pack(note, { + id: data.reply.userId, + }); + publishMainStream(data.reply.userId, "reply", packedReply); + + const webhooks = (await getActiveWebhooks()).filter( + (x) => + x.userId === data.reply?.userId && x.on.includes("reply"), + ); + for (const webhook of webhooks) { + webhookDeliver(webhook, "reply", { + note: packedReply, + }); + } + } + } + } + + // If it is renote + if (data.renote != null) { + const type = data.text ? "quote" : "renote"; + + // Notify + if (data.renote.userHost === null) { + const threadMuted = await NoteThreadMutings.findOneBy({ + userId: data.renote.userId, + threadId: data.renote.threadId || data.renote.id, + }); + + if (!threadMuted) { + nm.push(data.renote.userId, type); + } + } + // Fetch watchers + nmRelatedPromises.push( + notifyToWatchersOfRenotee(data.renote, user, nm, type), + ); + + // Publish event + if (user.id !== data.renote.userId && data.renote.userHost === null) { + const packedRenote = await Notes.pack(note, { + id: data.renote.userId, + }); + publishMainStream(data.renote.userId, "renote", packedRenote); + + const renote = data.renote; const webhooks = (await getActiveWebhooks()).filter( - (x) => x.userId === data.reply!.userId && x.on.includes("reply"), + (x) => x.userId === renote.userId && x.on.includes("renote"), ); for (const webhook of webhooks) { - webhookDeliver(webhook, "reply", { - note: packedReply, + webhookDeliver(webhook, "renote", { + note: packedRenote, }); } } } - } - // If it is renote - if (data.renote) { - const type = data.text ? "quote" : "renote"; + Promise.all(nmRelatedPromises).then(() => { + nm.deliver(); + }); - // Notify - if (data.renote.userHost === null) { - const threadMuted = await NoteThreadMutings.findOneBy({ - userId: data.renote.userId, - threadId: data.renote.threadId || data.renote.id, - }); + //#region AP deliver + if (Users.isLocalUser(user) && !dontFederateInitially) { + (async () => { + const noteActivity = await renderNoteOrRenoteActivity(data, note); + const dm = new DeliverManager(user, noteActivity); - if (!threadMuted) { - nm.push(data.renote.userId, type); - } - } - // Fetch watchers - nmRelatedPromises.push( - notifyToWatchersOfRenotee(data.renote, user, nm, type), - ); - - // Publish event - if (user.id !== data.renote.userId && data.renote.userHost === null) { - const packedRenote = await Notes.pack(note, { - id: data.renote.userId, - }); - publishMainStream(data.renote.userId, "renote", packedRenote); - - const renote = data.renote; - const webhooks = (await getActiveWebhooks()).filter( - (x) => x.userId === renote.userId && x.on.includes("renote"), - ); - for (const webhook of webhooks) { - webhookDeliver(webhook, "renote", { - note: packedRenote, - }); - } + // メンションされたリモートユーザーに配送 + for (const u of mentionedUsers.filter((u) => + Users.isRemoteUser(u), + )) { + dm.addDirectRecipe(u as IRemoteUser); + } + + // 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送 + if (data.reply?.userHost != null) { + const u = await Users.findOneBy({ id: data.reply.userId }); + if (u && Users.isRemoteUser(u)) dm.addDirectRecipe(u); + } + + // 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送 + if (data.renote?.userHost != null) { + const u = await Users.findOneBy({ id: data.renote.userId }); + if (u && Users.isRemoteUser(u)) dm.addDirectRecipe(u); + } + + // フォロワーに配送 + if (["public", "home", "followers"].includes(note.visibility)) { + dm.addFollowersRecipe(); + } + + if (["public"].includes(note.visibility)) { + deliverToRelays(user, noteActivity); + } + + dm.execute(); + })(); } + //#endregion } - Promise.all(nmRelatedPromises).then(() => { - nm.deliver(); - }); + if (data.channel) { + Channels.increment({ id: data.channel.id }, "notesCount", 1); + Channels.update(data.channel.id, { + lastNotedAt: new Date(), + }); - //#region AP deliver - if (Users.isLocalUser(user) && !dontFederateInitially) { - (async () => { - const noteActivity = await renderNoteOrRenoteActivity(data, note); - const dm = new DeliverManager(user, noteActivity); - - // メンションされたリモートユーザーに配送 - for (const u of mentionedUsers.filter((u) => Users.isRemoteUser(u))) { - dm.addDirectRecipe(u as IRemoteUser); + await Notes.countBy({ + userId: user.id, + channelId: data.channel.id, + }).then((count) => { + // この処理が行われるのはノート作成後なので、ノートが一つしかなかったら最初の投稿だと判断できる + // TODO: とはいえノートを削除して何回も投稿すればその分だけインクリメントされる雑さもあるのでどうにかしたい + if (count === 1 && data.channel != null) { + Channels.increment({ id: data.channel.id }, "usersCount", 1); } - - // 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送 - if (data.reply?.userHost != null) { - const u = await Users.findOneBy({ id: data.reply.userId }); - if (u && Users.isRemoteUser(u)) dm.addDirectRecipe(u); - } - - // 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送 - if (data.renote?.userHost != null) { - const u = await Users.findOneBy({ id: data.renote.userId }); - if (u && Users.isRemoteUser(u)) dm.addDirectRecipe(u); - } - - // フォロワーに配送 - if (["public", "home", "followers"].includes(note.visibility)) { - dm.addFollowersRecipe(); - } - - if (["public"].includes(note.visibility)) { - deliverToRelays(user, noteActivity); - } - - dm.execute(); - })(); + }); } - //#endregion } - if (data.channel) { - Channels.increment({ id: data.channel.id }, "notesCount", 1); - Channels.update(data.channel.id, { - lastNotedAt: new Date(), - }); + if (!dontFederateInitially) { + let publishKey: string; + let noteToPublish: Note; + const relays = await getCachedRelays(); - await Notes.countBy({ - userId: user.id, - channelId: data.channel.id, - }).then((count) => { - // この処理が行われるのはノート作成後なので、ノートが一つしかなかったら最初の投稿だと判断できる - // TODO: とはいえノートを削除して何回も投稿すればその分だけインクリメントされる雑さもあるのでどうにかしたい - if (count === 1 && data.channel != null) { - Channels.increment({ id: data.channel.id }, "usersCount", 1); + // Some relays (e.g., aode-relay) deliver posts by boosting them as + // Announce activities. In that case, user is the relay's actor. + const boostedByRelay = + !!user.inbox && relays.map((relay) => relay.inbox).includes(user.inbox); + + if (boostedByRelay && data.renote && data.renote.userHost) { + publishKey = `publishedNote:${data.renote.id}`; + noteToPublish = data.renote; + } else { + publishKey = `publishedNote:${note.id}`; + noteToPublish = note; + } + + const lock = new Mutex(redisClient, "publishedNote"); + await lock.acquire(); + try { + const published = (await redisClient.get(publishKey)) != null; + if (!published) { + await redisClient.set(publishKey, "done", "EX", 30); + if (noteToPublish.renoteId) { + // Prevents other threads from publishing the boosting post + await redisClient.set( + `publishedNote:${noteToPublish.renoteId}`, + "done", + "EX", + 30, + ); + } + publishNotesStream(noteToPublish); } - }); + } finally { + await lock.release(); + } } }); -async function renderNoteOrRenoteActivity(data: Option, note: Note) { +async function renderNoteOrRenoteActivity(data: NoteLike, note: Note) { if (data.localOnly) return null; const content = @@ -704,17 +701,17 @@ function incRenoteCount(renote: Note) { async function insertNote( user: { id: User["id"]; host: User["host"] }, - data: Option, + data: NoteLike, tags: string[], emojis: string[], - mentionedUsers: MinimumUser[], + mentionedUsers: UserLike[], ) { - if (data.createdAt === null || data.createdAt === undefined) { - data.createdAt = new Date(); - } - const insert = new Note({ + data.createdAt ??= new Date(); + + const note = new Note({ id: genIdAt(data.createdAt), createdAt: data.createdAt, + scheduledAt: data.scheduledAt ?? null, fileIds: data.files ? data.files.map((file) => file.id) : [], replyId: data.reply ? data.reply.id : null, renoteId: data.renote ? data.renote.id : null, @@ -743,7 +740,7 @@ async function insertNote( attachedFileTypes: data.files ? data.files.map((file) => file.type) : [], - // 以下非正規化データ + // denormalized fields replyUserId: data.reply ? data.reply.userId : null, replyUserHost: data.reply ? data.reply.userHost : null, renoteUserId: data.renote ? data.renote.userId : null, @@ -751,22 +748,22 @@ async function insertNote( userHost: user.host, }); - if (data.uri != null) insert.uri = data.uri; - if (data.url != null) insert.url = data.url; + if (data.uri != null) note.uri = data.uri; + if (data.url != null) note.url = data.url; // Append mentions data if (mentionedUsers.length > 0) { - insert.mentions = mentionedUsers.map((u) => u.id); - const profiles = await UserProfiles.findBy({ userId: In(insert.mentions) }); - insert.mentionedRemoteUsers = JSON.stringify( + note.mentions = mentionedUsers.map((u) => u.id); + const profiles = await UserProfiles.findBy({ userId: In(note.mentions) }); + note.mentionedRemoteUsers = JSON.stringify( mentionedUsers .filter((u) => Users.isRemoteUser(u)) .map((u) => { const profile = profiles.find((p) => p.userId === u.id); - const url = profile != null ? profile.url : null; + const url = profile?.url ?? null; return { uri: u.uri, - url: url == null ? undefined : url, + url: url ?? undefined, username: u.username, host: u.host, } as IMentionedRemoteUsers[0]; @@ -776,12 +773,12 @@ async function insertNote( // 投稿を作成 try { - if (insert.hasPoll) { + if (note.hasPoll) { // Start transaction await db.transaction(async (transactionalEntityManager) => { if (!data.poll) throw new Error("Empty poll data"); - await transactionalEntityManager.insert(Note, insert); + await transactionalEntityManager.insert(Note, note); let expiresAt: Date | null; if ( @@ -794,12 +791,12 @@ async function insertNote( } const poll = new Poll({ - noteId: insert.id, + noteId: note.id, choices: data.poll.choices, expiresAt, multiple: data.poll.multiple, votes: new Array(data.poll.choices.length).fill(0), - noteVisibility: insert.visibility, + noteVisibility: note.visibility, userId: user.id, userHost: user.host, }); @@ -807,10 +804,10 @@ async function insertNote( await transactionalEntityManager.insert(Poll, poll); }); } else { - await Notes.insert(insert); + await Notes.insert(note); } - return insert; + return note; } catch (e) { // duplicate key error if (isDuplicateKeyValueError(e)) { @@ -857,7 +854,7 @@ async function notifyToWatchersOfReplyee( } async function createMentionedEvents( - mentionedUsers: MinimumUser[], + mentionedUsers: UserLike[], note: Note, nm: NotificationManager, ) { diff --git a/packages/backend/src/services/note/delete.ts b/packages/backend/src/services/note/delete.ts index c709792fef..966d72a036 100644 --- a/packages/backend/src/services/note/delete.ts +++ b/packages/backend/src/services/note/delete.ts @@ -42,8 +42,12 @@ export default async function ( ) { const deletedAt = new Date(); + // Whether this is a scheduled "draft" post + const isDraft = note.scheduledAt != null; + // この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき if ( + !isDraft && note.renoteId && (await countSameRenotes(user.id, note.renoteId, note.id)) === 0 && deleteFromDb @@ -52,7 +56,7 @@ export default async function ( Notes.decrement({ id: note.renoteId }, "score", 1); } - if (note.replyId && deleteFromDb) { + if (!isDraft && note.replyId != null && deleteFromDb) { await Notes.decrement({ id: note.replyId }, "repliesCount", 1); } @@ -74,7 +78,7 @@ export default async function ( } //#region ローカルの投稿なら削除アクティビティを配送 - if (Users.isLocalUser(user) && !note.localOnly) { + if (!isDraft && Users.isLocalUser(user) && !note.localOnly) { let renote: Note | null = null; // if deletd note is renote