From c7e607ea5f58cd9d79c629e0a995139eae853537 Mon Sep 17 00:00:00 2001 From: ssmucny Date: Thu, 13 Apr 2023 19:31:27 -0400 Subject: [PATCH 01/35] Add event source code to models --- packages/backend/src/models/entities/Event.ts | 33 +++++++++++++++++++ packages/backend/src/models/entities/Note.ts | 17 +++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 packages/backend/src/models/entities/Event.ts diff --git a/packages/backend/src/models/entities/Event.ts b/packages/backend/src/models/entities/Event.ts new file mode 100644 index 0000000000..bf15838651 --- /dev/null +++ b/packages/backend/src/models/entities/Event.ts @@ -0,0 +1,33 @@ +import { Entity, Index, Column, PrimaryColumn } from 'typeorm'; +import { id } from '../id.js'; + +@Entity() +export class Event { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The start time of the event', + }) + public start: Date; + + @Column('timestamp with time zone', { + comment: 'The end of the event', + nullable: true, + }) + public end: Date; + + @Column({ + type: 'varchar', + length: 128, + comment: 'short name of event', + }) + public title: string; + + @Column('jsonb', { + default: {}, + comment: 'metadata mapping for event with more user configurable optional information', + }) + public metadata: Record; +} diff --git a/packages/backend/src/models/entities/Note.ts b/packages/backend/src/models/entities/Note.ts index df508b4dca..11cc6be34b 100644 --- a/packages/backend/src/models/entities/Note.ts +++ b/packages/backend/src/models/entities/Note.ts @@ -1,8 +1,9 @@ -import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; +import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne, OneToOne } from 'typeorm'; import { id } from '../id.js'; import { noteVisibilities } from '../../types.js'; import { User } from './User.js'; import { Channel } from './Channel.js'; +import { Event } from './Event.js'; import type { DriveFile } from './DriveFile.js'; @Entity() @@ -53,6 +54,20 @@ export class Note { }) public threadId: string | null; + @Index() + @Column({ + ...id(), + nullable: true, + comment: 'id of child event', + }) + public noteId: Event['id'] | null; + + @OneToOne(type => Event, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public note: Event | null; + // TODO: varcharにしたい @Column('text', { nullable: true, From 10e134a98001e4283e9aff4efa2654065f76baf9 Mon Sep 17 00:00:00 2001 From: ssmucny Date: Thu, 13 Apr 2023 19:55:50 -0400 Subject: [PATCH 02/35] entity typo --- packages/backend/src/models/entities/Note.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/models/entities/Note.ts b/packages/backend/src/models/entities/Note.ts index 11cc6be34b..ad776b73b0 100644 --- a/packages/backend/src/models/entities/Note.ts +++ b/packages/backend/src/models/entities/Note.ts @@ -58,15 +58,15 @@ export class Note { @Column({ ...id(), nullable: true, - comment: 'id of child event', + comment: 'The ID of child event', }) - public noteId: Event['id'] | null; + public eventId: Event['id'] | null; @OneToOne(type => Event, { onDelete: 'CASCADE', }) @JoinColumn() - public note: Event | null; + public event: Event | null; // TODO: varcharにしたい @Column('text', { From 4e6a4915d8de37de7b81036618f37fb0c352693f Mon Sep 17 00:00:00 2001 From: ssmucny Date: Thu, 13 Apr 2023 20:00:58 -0400 Subject: [PATCH 03/35] Event migration --- .../backend/migration/1681429921400-Event.js | 95 +++++++++++++++++++ packages/backend/src/postgres.ts | 2 + 2 files changed, 97 insertions(+) create mode 100644 packages/backend/migration/1681429921400-Event.js diff --git a/packages/backend/migration/1681429921400-Event.js b/packages/backend/migration/1681429921400-Event.js new file mode 100644 index 0000000000..29054b99e4 --- /dev/null +++ b/packages/backend/migration/1681429921400-Event.js @@ -0,0 +1,95 @@ +export class Event1681429921400 { + name = 'Event1681429921400' + + async up(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_2cd3b2a6b4cf0b910b260afe08"`); + await queryRunner.query(`DROP INDEX "public"."IDX_renote_muting_createdAt"`); + await queryRunner.query(`DROP INDEX "public"."IDX_renote_muting_muteeId"`); + await queryRunner.query(`DROP INDEX "public"."IDX_renote_muting_muterId"`); + await queryRunner.query(`CREATE TABLE "event" ("id" character varying(32) NOT NULL, "start" TIMESTAMP WITH TIME ZONE NOT NULL, "end" TIMESTAMP WITH TIME ZONE, "title" character varying(128) NOT NULL, "metadata" jsonb NOT NULL DEFAULT '{}', CONSTRAINT "PK_30c2f3bbaf6d34a55f8ae6e4614" PRIMARY KEY ("id")); COMMENT ON COLUMN "event"."start" IS 'The start time of the event'; COMMENT ON COLUMN "event"."end" IS 'The end of the event'; COMMENT ON COLUMN "event"."title" IS 'short name of event'; COMMENT ON COLUMN "event"."metadata" IS 'metadata mapping for event with more user configurable optional information'`); + await queryRunner.query(`CREATE INDEX "IDX_785ee5fc1ea38a1b9b38ff88e5" ON "event" ("start") `); + await queryRunner.query(`ALTER TABLE "note" ADD "eventId" character varying(32)`); + await queryRunner.query(`ALTER TABLE "note" ADD CONSTRAINT "UQ_3af9380f266b7046cce9c992197" UNIQUE ("eventId")`); + await queryRunner.query(`COMMENT ON COLUMN "note"."eventId" IS 'The ID of child event'`); + await queryRunner.query(`COMMENT ON COLUMN "user"."isRoot" IS 'Whether the User is the root.'`); + await queryRunner.query(`COMMENT ON COLUMN "ad"."startsAt" IS 'The expired date of the Ad.'`); + await queryRunner.query(`ALTER TABLE "antenna" ALTER COLUMN "lastUsedAt" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "mascotImageUrl" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "errorImageUrl" DROP DEFAULT`); + await queryRunner.query(`COMMENT ON COLUMN "renote_muting"."createdAt" IS 'The created date of the Muting.'`); + await queryRunner.query(`COMMENT ON COLUMN "renote_muting"."muteeId" IS 'The mutee user ID.'`); + await queryRunner.query(`COMMENT ON COLUMN "renote_muting"."muterId" IS 'The muter user ID.'`); + await queryRunner.query(`ALTER TABLE "poll" DROP CONSTRAINT "FK_da851e06d0dfe2ef397d8b1bf1b"`); + await queryRunner.query(`ALTER TABLE "poll" DROP CONSTRAINT "UQ_da851e06d0dfe2ef397d8b1bf1b"`); + await queryRunner.query(`ALTER TABLE "promo_note" DROP CONSTRAINT "FK_e263909ca4fe5d57f8d4230dd5c"`); + await queryRunner.query(`ALTER TABLE "promo_note" DROP CONSTRAINT "UQ_e263909ca4fe5d57f8d4230dd5c"`); + await queryRunner.query(`ALTER TABLE "user_keypair" DROP CONSTRAINT "FK_f4853eb41ab722fe05f81cedeb6"`); + await queryRunner.query(`ALTER TABLE "user_keypair" DROP CONSTRAINT "UQ_f4853eb41ab722fe05f81cedeb6"`); + await queryRunner.query(`ALTER TABLE "user_profile" DROP CONSTRAINT "FK_51cb79b5555effaf7d69ba1cff9"`); + await queryRunner.query(`ALTER TABLE "user_profile" DROP CONSTRAINT "UQ_51cb79b5555effaf7d69ba1cff9"`); + await queryRunner.query(`ALTER TABLE "user_publickey" DROP CONSTRAINT "FK_10c146e4b39b443ede016f6736d"`); + await queryRunner.query(`ALTER TABLE "user_publickey" DROP CONSTRAINT "UQ_10c146e4b39b443ede016f6736d"`); + await queryRunner.query(`CREATE INDEX "IDX_3fcc2c589eaefc205e0714b99c" ON "ad" ("startsAt") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_c71faf11f0a28a5c0bb506203c" ON "channel_favorite" ("userId", "channelId") `); + await queryRunner.query(`CREATE INDEX "IDX_3af9380f266b7046cce9c99219" ON "note" ("eventId") `); + await queryRunner.query(`CREATE INDEX "IDX_f7b9d338207e40e768e4a5265a" ON "instance" ("firstRetrievedAt") `); + await queryRunner.query(`CREATE INDEX "IDX_d1259a2c2b7bb413ff449e8711" ON "renote_muting" ("createdAt") `); + await queryRunner.query(`CREATE INDEX "IDX_7eac97594bcac5ffcf2068089b" ON "renote_muting" ("muteeId") `); + await queryRunner.query(`CREATE INDEX "IDX_7aa72a5fe76019bfe8e5e0e8b7" ON "renote_muting" ("muterId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_0d801c609cec4e9eb4b6b4490c" ON "renote_muting" ("muterId", "muteeId") `); + await queryRunner.query(`ALTER TABLE "note" ADD CONSTRAINT "FK_3af9380f266b7046cce9c992197" FOREIGN KEY ("eventId") REFERENCES "event"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "renote_muting" ADD CONSTRAINT "FK_7eac97594bcac5ffcf2068089b6" FOREIGN KEY ("muteeId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "renote_muting" ADD CONSTRAINT "FK_7aa72a5fe76019bfe8e5e0e8b7d" FOREIGN KEY ("muterId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "poll" ADD CONSTRAINT "FK_da851e06d0dfe2ef397d8b1bf1b" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "promo_note" ADD CONSTRAINT "FK_e263909ca4fe5d57f8d4230dd5c" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_keypair" ADD CONSTRAINT "FK_f4853eb41ab722fe05f81cedeb6" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_profile" ADD CONSTRAINT "FK_51cb79b5555effaf7d69ba1cff9" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_publickey" ADD CONSTRAINT "FK_10c146e4b39b443ede016f6736d" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_publickey" DROP CONSTRAINT "FK_10c146e4b39b443ede016f6736d"`); + await queryRunner.query(`ALTER TABLE "user_profile" DROP CONSTRAINT "FK_51cb79b5555effaf7d69ba1cff9"`); + await queryRunner.query(`ALTER TABLE "user_keypair" DROP CONSTRAINT "FK_f4853eb41ab722fe05f81cedeb6"`); + await queryRunner.query(`ALTER TABLE "promo_note" DROP CONSTRAINT "FK_e263909ca4fe5d57f8d4230dd5c"`); + await queryRunner.query(`ALTER TABLE "poll" DROP CONSTRAINT "FK_da851e06d0dfe2ef397d8b1bf1b"`); + await queryRunner.query(`ALTER TABLE "renote_muting" DROP CONSTRAINT "FK_7aa72a5fe76019bfe8e5e0e8b7d"`); + await queryRunner.query(`ALTER TABLE "renote_muting" DROP CONSTRAINT "FK_7eac97594bcac5ffcf2068089b6"`); + await queryRunner.query(`ALTER TABLE "note" DROP CONSTRAINT "FK_3af9380f266b7046cce9c992197"`); + await queryRunner.query(`DROP INDEX "public"."IDX_0d801c609cec4e9eb4b6b4490c"`); + await queryRunner.query(`DROP INDEX "public"."IDX_7aa72a5fe76019bfe8e5e0e8b7"`); + await queryRunner.query(`DROP INDEX "public"."IDX_7eac97594bcac5ffcf2068089b"`); + await queryRunner.query(`DROP INDEX "public"."IDX_d1259a2c2b7bb413ff449e8711"`); + await queryRunner.query(`DROP INDEX "public"."IDX_f7b9d338207e40e768e4a5265a"`); + await queryRunner.query(`DROP INDEX "public"."IDX_3af9380f266b7046cce9c99219"`); + await queryRunner.query(`DROP INDEX "public"."IDX_c71faf11f0a28a5c0bb506203c"`); + await queryRunner.query(`DROP INDEX "public"."IDX_3fcc2c589eaefc205e0714b99c"`); + await queryRunner.query(`ALTER TABLE "user_publickey" ADD CONSTRAINT "UQ_10c146e4b39b443ede016f6736d" UNIQUE ("userId")`); + await queryRunner.query(`ALTER TABLE "user_publickey" ADD CONSTRAINT "FK_10c146e4b39b443ede016f6736d" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_profile" ADD CONSTRAINT "UQ_51cb79b5555effaf7d69ba1cff9" UNIQUE ("userId")`); + await queryRunner.query(`ALTER TABLE "user_profile" ADD CONSTRAINT "FK_51cb79b5555effaf7d69ba1cff9" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_keypair" ADD CONSTRAINT "UQ_f4853eb41ab722fe05f81cedeb6" UNIQUE ("userId")`); + await queryRunner.query(`ALTER TABLE "user_keypair" ADD CONSTRAINT "FK_f4853eb41ab722fe05f81cedeb6" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "promo_note" ADD CONSTRAINT "UQ_e263909ca4fe5d57f8d4230dd5c" UNIQUE ("noteId")`); + await queryRunner.query(`ALTER TABLE "promo_note" ADD CONSTRAINT "FK_e263909ca4fe5d57f8d4230dd5c" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "poll" ADD CONSTRAINT "UQ_da851e06d0dfe2ef397d8b1bf1b" UNIQUE ("noteId")`); + await queryRunner.query(`ALTER TABLE "poll" ADD CONSTRAINT "FK_da851e06d0dfe2ef397d8b1bf1b" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`COMMENT ON COLUMN "renote_muting"."muterId" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "renote_muting"."muteeId" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "renote_muting"."createdAt" IS NULL`); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "errorImageUrl" SET DEFAULT 'https://xn--931a.moe/aiart/yubitun.png'`); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "mascotImageUrl" SET DEFAULT '/assets/ai.png'`); + await queryRunner.query(`ALTER TABLE "antenna" ALTER COLUMN "lastUsedAt" SET DEFAULT '2023-04-13 18:46:24.168209-04'`); + await queryRunner.query(`COMMENT ON COLUMN "ad"."startsAt" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "user"."isRoot" IS 'Whether the User is the admin.'`); + await queryRunner.query(`COMMENT ON COLUMN "note"."eventId" IS 'The ID of child event'`); + await queryRunner.query(`ALTER TABLE "note" DROP CONSTRAINT "UQ_3af9380f266b7046cce9c992197"`); + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "eventId"`); + await queryRunner.query(`DROP INDEX "public"."IDX_785ee5fc1ea38a1b9b38ff88e5"`); + await queryRunner.query(`DROP TABLE "event"`); + await queryRunner.query(`CREATE INDEX "IDX_renote_muting_muterId" ON "muting" ("muterId") `); + await queryRunner.query(`CREATE INDEX "IDX_renote_muting_muteeId" ON "muting" ("muteeId") `); + await queryRunner.query(`CREATE INDEX "IDX_renote_muting_createdAt" ON "muting" ("createdAt") `); + await queryRunner.query(`CREATE INDEX "IDX_2cd3b2a6b4cf0b910b260afe08" ON "instance" ("firstRetrievedAt") `); + } +} diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index bb21ed827e..4bbf1d0f4d 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -24,6 +24,7 @@ import { ClipFavorite } from '@/models/entities/ClipFavorite.js'; import { DriveFile } from '@/models/entities/DriveFile.js'; import { DriveFolder } from '@/models/entities/DriveFolder.js'; import { Emoji } from '@/models/entities/Emoji.js'; +import { Event } from '@/models/entities/Event.js'; import { Following } from '@/models/entities/Following.js'; import { FollowRequest } from '@/models/entities/FollowRequest.js'; import { GalleryLike } from '@/models/entities/GalleryLike.js'; @@ -155,6 +156,7 @@ export const entities = [ Poll, PollVote, Emoji, + Event, Hashtag, SwSubscription, AbuseUserReport, From 8ab1d62444ece59d36fb83a7632b04f32806d3be Mon Sep 17 00:00:00 2001 From: ssmucny Date: Sun, 16 Apr 2023 15:25:54 -0400 Subject: [PATCH 04/35] Changed DB to have events behave like polls --- packages/backend/src/models/entities/Event.ts | 11 +++++++++-- packages/backend/src/models/entities/Note.ts | 18 ++++-------------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/packages/backend/src/models/entities/Event.ts b/packages/backend/src/models/entities/Event.ts index bf15838651..779c491cca 100644 --- a/packages/backend/src/models/entities/Event.ts +++ b/packages/backend/src/models/entities/Event.ts @@ -1,10 +1,17 @@ -import { Entity, Index, Column, PrimaryColumn } from 'typeorm'; +import { Entity, Index, Column, PrimaryColumn, OneToOne, JoinColumn } from 'typeorm'; import { id } from '../id.js'; +import { Note } from './Note.js'; @Entity() export class Event { @PrimaryColumn(id()) - public id: string; + public noteId: Note['id']; + + @OneToOne(type => Note, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public note: Note | null; @Index() @Column('timestamp with time zone', { diff --git a/packages/backend/src/models/entities/Note.ts b/packages/backend/src/models/entities/Note.ts index ad776b73b0..4af007abc3 100644 --- a/packages/backend/src/models/entities/Note.ts +++ b/packages/backend/src/models/entities/Note.ts @@ -1,9 +1,8 @@ -import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne, OneToOne } from 'typeorm'; +import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; import { id } from '../id.js'; import { noteVisibilities } from '../../types.js'; import { User } from './User.js'; import { Channel } from './Channel.js'; -import { Event } from './Event.js'; import type { DriveFile } from './DriveFile.js'; @Entity() @@ -54,19 +53,10 @@ export class Note { }) public threadId: string | null; - @Index() - @Column({ - ...id(), - nullable: true, - comment: 'The ID of child event', + @Column('boolean', { + default: false, }) - public eventId: Event['id'] | null; - - @OneToOne(type => Event, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public event: Event | null; + public isEvent: boolean; // TODO: varcharにしたい @Column('text', { From a56561f8aa6a08c3bc2e2d9b86ce88f3eee7905d Mon Sep 17 00:00:00 2001 From: ssmucny Date: Sun, 16 Apr 2023 15:32:39 -0400 Subject: [PATCH 05/35] add new migration --- .../backend/migration/1681673280586-event.js | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 packages/backend/migration/1681673280586-event.js diff --git a/packages/backend/migration/1681673280586-event.js b/packages/backend/migration/1681673280586-event.js new file mode 100644 index 0000000000..7e464dbcf9 --- /dev/null +++ b/packages/backend/migration/1681673280586-event.js @@ -0,0 +1,29 @@ +export class Event1681673280586 { + name = 'Event1681673280586' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" DROP CONSTRAINT "FK_3af9380f266b7046cce9c992197"`); + await queryRunner.query(`DROP INDEX "public"."IDX_3af9380f266b7046cce9c99219"`); + await queryRunner.query(`ALTER TABLE "note" RENAME COLUMN "eventId" TO "isEvent"`); + await queryRunner.query(`ALTER TABLE "note" RENAME CONSTRAINT "UQ_3af9380f266b7046cce9c992197" TO "UQ_16484b50d1ee91555d4b8821ac3"`); + await queryRunner.query(`ALTER TABLE "event" RENAME COLUMN "id" TO "noteId"`); + await queryRunner.query(`ALTER TABLE "event" RENAME CONSTRAINT "PK_30c2f3bbaf6d34a55f8ae6e4614" TO "PK_2b481f231cd035e84390072bf7b"`); + await queryRunner.query(`ALTER TABLE "note" DROP CONSTRAINT "UQ_16484b50d1ee91555d4b8821ac3"`); + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "isEvent"`); + await queryRunner.query(`ALTER TABLE "note" ADD "isEvent" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "event" ADD CONSTRAINT "FK_2b481f231cd035e84390072bf7b" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "event" DROP CONSTRAINT "FK_2b481f231cd035e84390072bf7b"`); + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "isEvent"`); + await queryRunner.query(`ALTER TABLE "note" ADD "isEvent" character varying(32)`); + await queryRunner.query(`ALTER TABLE "note" ADD CONSTRAINT "UQ_16484b50d1ee91555d4b8821ac3" UNIQUE ("isEvent")`); + await queryRunner.query(`ALTER TABLE "event" RENAME CONSTRAINT "PK_2b481f231cd035e84390072bf7b" TO "PK_30c2f3bbaf6d34a55f8ae6e4614"`); + await queryRunner.query(`ALTER TABLE "event" RENAME COLUMN "noteId" TO "id"`); + await queryRunner.query(`ALTER TABLE "note" RENAME CONSTRAINT "UQ_16484b50d1ee91555d4b8821ac3" TO "UQ_3af9380f266b7046cce9c992197"`); + await queryRunner.query(`ALTER TABLE "note" RENAME COLUMN "isEvent" TO "eventId"`); + await queryRunner.query(`CREATE INDEX "IDX_3af9380f266b7046cce9c99219" ON "note" ("eventId") `); + await queryRunner.query(`ALTER TABLE "note" ADD CONSTRAINT "FK_3af9380f266b7046cce9c992197" FOREIGN KEY ("eventId") REFERENCES "event"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } +} From 173a901eee2b1952e11a20b23f5b3c26abde83e5 Mon Sep 17 00:00:00 2001 From: ssmucny Date: Sun, 16 Apr 2023 16:13:11 -0400 Subject: [PATCH 06/35] denormalize Events/Notes relation --- .../backend/migration/1681675881633-event.js | 29 ++++++++++++++ packages/backend/src/models/entities/Event.ts | 39 +++++++++++++++++++ packages/backend/src/models/entities/Note.ts | 2 +- packages/misskey-js/src/entities.ts | 6 +++ 4 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 packages/backend/migration/1681675881633-event.js diff --git a/packages/backend/migration/1681675881633-event.js b/packages/backend/migration/1681675881633-event.js new file mode 100644 index 0000000000..562d04be0d --- /dev/null +++ b/packages/backend/migration/1681675881633-event.js @@ -0,0 +1,29 @@ +export class Event1681675881633 { + name = 'Event1681675881633' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" RENAME COLUMN "isEvent" TO "hasEvent"`); + await queryRunner.query(`CREATE TYPE "public"."event_notevisibility_enum" AS ENUM('public', 'home', 'followers', 'specified')`); + await queryRunner.query(`ALTER TABLE "event" ADD "noteVisibility" "public"."event_notevisibility_enum" NOT NULL`); + await queryRunner.query(`COMMENT ON COLUMN "event"."noteVisibility" IS '[Denormalized]'`); + await queryRunner.query(`ALTER TABLE "event" ADD "userId" character varying(32) NOT NULL`); + await queryRunner.query(`COMMENT ON COLUMN "event"."userId" IS '[Denormalized]'`); + await queryRunner.query(`ALTER TABLE "event" ADD "userHost" character varying(128)`); + await queryRunner.query(`COMMENT ON COLUMN "event"."userHost" IS '[Denormalized]'`); + await queryRunner.query(`CREATE INDEX "IDX_01cd2b829e0263917bf570cb67" ON "event" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_f6ba57dff679ccbcfe004698ec" ON "event" ("userHost") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_f6ba57dff679ccbcfe004698ec"`); + await queryRunner.query(`DROP INDEX "public"."IDX_01cd2b829e0263917bf570cb67"`); + await queryRunner.query(`COMMENT ON COLUMN "event"."userHost" IS '[Denormalized]'`); + await queryRunner.query(`ALTER TABLE "event" DROP COLUMN "userHost"`); + await queryRunner.query(`COMMENT ON COLUMN "event"."userId" IS '[Denormalized]'`); + await queryRunner.query(`ALTER TABLE "event" DROP COLUMN "userId"`); + await queryRunner.query(`COMMENT ON COLUMN "event"."noteVisibility" IS '[Denormalized]'`); + await queryRunner.query(`ALTER TABLE "event" DROP COLUMN "noteVisibility"`); + await queryRunner.query(`DROP TYPE "public"."event_notevisibility_enum"`); + await queryRunner.query(`ALTER TABLE "note" RENAME COLUMN "hasEvent" TO "isEvent"`); + } +} diff --git a/packages/backend/src/models/entities/Event.ts b/packages/backend/src/models/entities/Event.ts index 779c491cca..9d986b7c85 100644 --- a/packages/backend/src/models/entities/Event.ts +++ b/packages/backend/src/models/entities/Event.ts @@ -1,6 +1,8 @@ import { Entity, Index, Column, PrimaryColumn, OneToOne, JoinColumn } from 'typeorm'; import { id } from '../id.js'; +import { noteVisibilities } from '../../types.js'; import { Note } from './Note.js'; +import type { User } from './User.js'; @Entity() export class Event { @@ -37,4 +39,41 @@ export class Event { comment: 'metadata mapping for event with more user configurable optional information', }) public metadata: Record; + + //#region Denormalized fields + @Column('enum', { + enum: noteVisibilities, + comment: '[Denormalized]', + }) + public noteVisibility: typeof noteVisibilities[number]; + + @Index() + @Column({ + ...id(), + comment: '[Denormalized]', + }) + public userId: User['id']; + + @Index() + @Column('varchar', { + length: 128, nullable: true, + comment: '[Denormalized]', + }) + public userHost: string | null; + //#endregion + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} + +export type IEvent = { + start: Date; + end: Date | null + title: string; + metadata: Record; } diff --git a/packages/backend/src/models/entities/Note.ts b/packages/backend/src/models/entities/Note.ts index 4af007abc3..51339fd7ba 100644 --- a/packages/backend/src/models/entities/Note.ts +++ b/packages/backend/src/models/entities/Note.ts @@ -56,7 +56,7 @@ export class Note { @Column('boolean', { default: false, }) - public isEvent: boolean; + public hasEvent: boolean; // TODO: varcharにしたい @Column('text', { diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts index 0c90e44494..5656e6b8cf 100644 --- a/packages/misskey-js/src/entities.ts +++ b/packages/misskey-js/src/entities.ts @@ -150,6 +150,12 @@ export type Note = { replyId: Note['id']; renote?: Note; renoteId: Note['id']; + event?: { + title: string, + start: DateString, + end: DateString | null, + metadata: Record, + }; files: DriveFile[]; fileIds: DriveFile['id'][]; visibility: 'public' | 'home' | 'followers' | 'specified'; From 453d25ff3769c786444713458fb3165085a7e76d Mon Sep 17 00:00:00 2001 From: ssmucny Date: Sun, 16 Apr 2023 16:13:43 -0400 Subject: [PATCH 07/35] Add Event to create api --- .../backend/src/core/NoteCreateService.ts | 44 ++++++++++++++----- .../src/server/api/endpoints/notes/create.ts | 16 +++++++ 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 32e4fe7f8a..5d6d88e271 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -8,6 +8,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf import { extractHashtags } from '@/misc/extract-hashtags.js'; import type { IMentionedRemoteUsers } from '@/models/entities/Note.js'; import { Note } from '@/models/entities/Note.js'; +import { Event, IEvent } from '@/models/entities/Event.js'; import type { ChannelFollowingsRepository, ChannelsRepository, InstancesRepository, MutedNotesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; import type { DriveFile } from '@/models/entities/DriveFile.js'; import type { App } from '@/models/entities/App.js'; @@ -126,6 +127,7 @@ type Option = { renote?: Note | null; files?: DriveFile[] | null; poll?: IPoll | null; + event?: IEvent | null; localOnly?: boolean | null; reactionAcceptance?: Note['reactionAcceptance']; cw?: string | null; @@ -358,6 +360,7 @@ export class NoteCreateService implements OnApplicationShutdown { name: data.name, text: data.text, hasPoll: data.poll != null, + hasEvent: data.event != null, cw: data.cw == null ? null : data.cw, tags: tags.map(tag => normalizeForSearch(tag)), emojis, @@ -402,23 +405,40 @@ export class NoteCreateService implements OnApplicationShutdown { // 投稿を作成 try { - if (insert.hasPoll) { + if (insert.hasPoll || insert.hasEvent) { // Start transaction await this.db.transaction(async transactionalEntityManager => { await transactionalEntityManager.insert(Note, insert); - const poll = new Poll({ - noteId: insert.id, - choices: data.poll!.choices, - expiresAt: data.poll!.expiresAt, - multiple: data.poll!.multiple, - votes: new Array(data.poll!.choices.length).fill(0), - noteVisibility: insert.visibility, - userId: user.id, - userHost: user.host, - }); + if (insert.hasPoll) { + const poll = new Poll({ + noteId: insert.id, + choices: data.poll!.choices, + expiresAt: data.poll!.expiresAt, + multiple: data.poll!.multiple, + votes: new Array(data.poll!.choices.length).fill(0), + noteVisibility: insert.visibility, + userId: user.id, + userHost: user.host, + }); - await transactionalEntityManager.insert(Poll, poll); + await transactionalEntityManager.insert(Poll, poll); + } + + if (insert.hasEvent) { + const event = new Event({ + noteId: insert.id, + start: data.event!.start, + end: data.event!.end ?? undefined, + title: data.event!.title, + metadata: data.event!.metadata, + noteVisibility: insert.visibility, + userId: user.id, + userHost: user.host, + }); + + await transactionalEntityManager.insert(Event, event); + } }); } else { await this.notesRepository.insert(insert); diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 69fafcb9c7..7141c37fcd 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -144,6 +144,16 @@ export const paramDef = { }, required: ['choices'], }, + event: { + type: 'object', + nullable: true, + properties: { + title: { type: 'string', minLength: 1, maxLength: 128, nullable: false }, + start: { type: 'integer', nullable: false }, + end: { type: 'integer', nullable: true }, + metadata: { type: 'object' }, + }, + }, }, // (re)note with text, files and poll are optional anyOf: [ @@ -279,6 +289,12 @@ export default class extends Endpoint { text: ps.text ?? undefined, reply, renote, + event: ps.event ? { + start: new Date(ps.event.start!), + end: ps.event.end ? new Date(ps.event.end) : null, + title: ps.event.title!, + metadata: ps.event.metadata ?? {}, + } : undefined, cw: ps.cw, localOnly: ps.localOnly, reactionAcceptance: ps.reactionAcceptance, From 44d05d58c4553df82d1b78adf35b03409d74ea66 Mon Sep 17 00:00:00 2001 From: ssmucny Date: Sun, 16 Apr 2023 17:27:34 -0400 Subject: [PATCH 08/35] Add event to repositories (getters) --- .../src/core/entities/NoteEntityService.ts | 17 ++++++++++++++++- packages/backend/src/di-symbols.ts | 1 + packages/backend/src/models/RepositoryModule.ts | 10 +++++++++- packages/backend/src/models/index.ts | 3 +++ 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 26debd6adc..ed93c7b146 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -9,7 +9,7 @@ import { awaitAll } from '@/misc/prelude/await-all.js'; import type { User } from '@/models/entities/User.js'; import type { Note } from '@/models/entities/Note.js'; import type { NoteReaction } from '@/models/entities/NoteReaction.js'; -import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, DriveFilesRepository } from '@/models/index.js'; +import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, DriveFilesRepository, EventsRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import { isNotNull } from '@/misc/is-not-null.js'; import type { OnModuleInit } from '@nestjs/common'; @@ -43,6 +43,9 @@ export class NoteEntityService implements OnModuleInit { @Inject(DI.pollsRepository) private pollsRepository: PollsRepository, + @Inject(DI.eventsRepository) + private eventsRepository: EventsRepository, + @Inject(DI.pollVotesRepository) private pollVotesRepository: PollVotesRepository, @@ -169,6 +172,17 @@ export class NoteEntityService implements OnModuleInit { }; } + @bindThis + private async populateEvent(note: Note) { + const event = await this.eventsRepository.findOneByOrFail({ noteId: note.id }); + return { + title: event.title, + start: event.start, + end: event.end, + metadata: event.metadata, + }; + } + @bindThis private async populateMyReaction(note: Note, meId: User['id'], _hint_?: { myReactions: Map; @@ -352,6 +366,7 @@ export class NoteEntityService implements OnModuleInit { }) : undefined, poll: note.hasPoll ? this.populatePoll(note, meId) : undefined, + event: note.hasEvent ? this.populateEvent(note) : undefined, ...(meId ? { myReaction: this.populateMyReaction(note, meId, options?._hint_), diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index d4b1fb31b1..44cb1a4121 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -32,6 +32,7 @@ export const DI = { followRequestsRepository: Symbol('followRequestsRepository'), instancesRepository: Symbol('instancesRepository'), emojisRepository: Symbol('emojisRepository'), + eventsRepository: Symbol('eventsRepository'), driveFilesRepository: Symbol('driveFilesRepository'), driveFoldersRepository: Symbol('driveFoldersRepository'), metasRepository: Symbol('metasRepository'), diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 7be7b81904..7d2655f499 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite } from './index.js'; +import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, Event, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite } from './index.js'; import type { DataSource } from 'typeorm'; import type { Provider } from '@nestjs/common'; @@ -160,6 +160,12 @@ const $emojisRepository: Provider = { inject: [DI.db], }; +const $eventsRepository: Provider = { + provide: DI.eventsRepository, + useFactory: (db: DataSource) => db.getRepository(Event), + inject: [DI.db], +}; + const $driveFilesRepository: Provider = { provide: DI.driveFilesRepository, useFactory: (db: DataSource) => db.getRepository(DriveFile), @@ -418,6 +424,7 @@ const $roleAssignmentsRepository: Provider = { $followRequestsRepository, $instancesRepository, $emojisRepository, + $eventsRepository, $driveFilesRepository, $driveFoldersRepository, $metasRepository, @@ -484,6 +491,7 @@ const $roleAssignmentsRepository: Provider = { $followRequestsRepository, $instancesRepository, $emojisRepository, + $eventsRepository, $driveFilesRepository, $driveFoldersRepository, $metasRepository, diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index 48d6e15f2a..bd60d4e576 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -16,6 +16,7 @@ import { ClipFavorite } from '@/models/entities/ClipFavorite.js'; import { DriveFile } from '@/models/entities/DriveFile.js'; import { DriveFolder } from '@/models/entities/DriveFolder.js'; import { Emoji } from '@/models/entities/Emoji.js'; +import { Event } from '@/models/entities/Event.js'; import { Following } from '@/models/entities/Following.js'; import { FollowRequest } from '@/models/entities/FollowRequest.js'; import { GalleryLike } from '@/models/entities/GalleryLike.js'; @@ -83,6 +84,7 @@ export { DriveFile, DriveFolder, Emoji, + Event, Following, FollowRequest, GalleryLike, @@ -149,6 +151,7 @@ export type ClipFavoritesRepository = Repository; export type DriveFilesRepository = Repository; export type DriveFoldersRepository = Repository; export type EmojisRepository = Repository; +export type EventsRepository = Repository; export type FollowingsRepository = Repository; export type FollowRequestsRepository = Repository; export type GalleryLikesRepository = Repository; From 1a797eee358a1db5abbcc0b93614e9ed78d7c977 Mon Sep 17 00:00:00 2001 From: ssmucny Date: Sun, 16 Apr 2023 21:59:18 -0400 Subject: [PATCH 09/35] Added notes/events/search endpoint logic --- .../backend/src/server/api/EndpointsModule.ts | 4 + packages/backend/src/server/api/endpoints.ts | 2 + .../api/endpoints/notes/events/search.ts | 134 ++ packages/misskey-js/etc/misskey-js.api.md | 1846 +++++++++-------- packages/misskey-js/src/api.types.ts | 1 + 5 files changed, 1074 insertions(+), 913 deletions(-) create mode 100644 packages/backend/src/server/api/endpoints/notes/events/search.ts diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index ca89d82853..dcf09de83b 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -254,6 +254,7 @@ import * as ep___notes_localTimeline from './endpoints/notes/local-timeline.js'; import * as ep___notes_mentions from './endpoints/notes/mentions.js'; import * as ep___notes_polls_recommendation from './endpoints/notes/polls/recommendation.js'; import * as ep___notes_polls_vote from './endpoints/notes/polls/vote.js'; +import * as ep___notes_events_search from './endpoints/notes/events/search.js'; import * as ep___notes_reactions from './endpoints/notes/reactions.js'; import * as ep___notes_reactions_create from './endpoints/notes/reactions/create.js'; import * as ep___notes_reactions_delete from './endpoints/notes/reactions/delete.js'; @@ -588,6 +589,7 @@ const $notes_localTimeline: Provider = { provide: 'ep:notes/local-timeline', use const $notes_mentions: Provider = { provide: 'ep:notes/mentions', useClass: ep___notes_mentions.default }; const $notes_polls_recommendation: Provider = { provide: 'ep:notes/polls/recommendation', useClass: ep___notes_polls_recommendation.default }; const $notes_polls_vote: Provider = { provide: 'ep:notes/polls/vote', useClass: ep___notes_polls_vote.default }; +const $notes_events_search: Provider = { provide: 'ep:notes/events/search', useClass: ep___notes_events_search.default }; const $notes_reactions: Provider = { provide: 'ep:notes/reactions', useClass: ep___notes_reactions.default }; const $notes_reactions_create: Provider = { provide: 'ep:notes/reactions/create', useClass: ep___notes_reactions_create.default }; const $notes_reactions_delete: Provider = { provide: 'ep:notes/reactions/delete', useClass: ep___notes_reactions_delete.default }; @@ -926,6 +928,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $notes_mentions, $notes_polls_recommendation, $notes_polls_vote, + $notes_events_search, $notes_reactions, $notes_reactions_create, $notes_reactions_delete, @@ -1258,6 +1261,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $notes_mentions, $notes_polls_recommendation, $notes_polls_vote, + $notes_events_search, $notes_reactions, $notes_reactions_create, $notes_reactions_delete, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index dab897117d..292f18a880 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -254,6 +254,7 @@ import * as ep___notes_localTimeline from './endpoints/notes/local-timeline.js'; import * as ep___notes_mentions from './endpoints/notes/mentions.js'; import * as ep___notes_polls_recommendation from './endpoints/notes/polls/recommendation.js'; import * as ep___notes_polls_vote from './endpoints/notes/polls/vote.js'; +import * as ep___notes_events_search from './endpoints/notes/events/search.js'; import * as ep___notes_reactions from './endpoints/notes/reactions.js'; import * as ep___notes_reactions_create from './endpoints/notes/reactions/create.js'; import * as ep___notes_reactions_delete from './endpoints/notes/reactions/delete.js'; @@ -586,6 +587,7 @@ const eps = [ ['notes/mentions', ep___notes_mentions], ['notes/polls/recommendation', ep___notes_polls_recommendation], ['notes/polls/vote', ep___notes_polls_vote], + ['notes/events/search', ep___notes_events_search], ['notes/reactions', ep___notes_reactions], ['notes/reactions/create', ep___notes_reactions_create], ['notes/reactions/delete', ep___notes_reactions_delete], diff --git a/packages/backend/src/server/api/endpoints/notes/events/search.ts b/packages/backend/src/server/api/endpoints/notes/events/search.ts new file mode 100644 index 0000000000..51c192714d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/events/search.ts @@ -0,0 +1,134 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; +import { Event } from '@/models/entities/Event.js'; +import type { NotesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import type { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: false, + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Note', + }, + }, + + errors: { + unavailable: { + message: 'Search of notes unavailable.', + code: 'UNAVAILABLE', + id: '0b44998d-77aa-4427-80d0-d2c9b8523011', + }, + invalidParam: { + message: 'Invalid Parameter', + code: 'INVALID_PARAM', + id: 'e70903d3-0aa2-44d5-a955-4de5723c603d', + } + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + host: { + type: 'string', + nullable: true, + description: 'The local host is represented with `null`.', + }, + users: { type: 'array', nullable: true, items: { type: 'object', format: 'misskey:id' } }, + sinceDate: { type: 'integer', nullable: true }, + untilDate: { type: 'integer', nullable: true }, + filters: { + type: 'object', + nullable: true, + description: 'mapping of string -> [string] that filters events based on metadata', + }, + sortBy: { type: 'string', nullable: true, default: 'startDate', enum: ['startDate', 'createdAt'] }, + }, +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private noteEntityService: NoteEntityService, + private queryService: QueryService, + private roleService: RoleService, + ) { + super(meta, paramDef, async (ps, me) => { + const policies = await this.roleService.getUserPolicies(me ? me.id : null); + if (!policies.canSearchNotes) { + throw new ApiError(meta.errors.unavailable); + } + + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId); + + if (ps.users) { + if (ps.users.length < 1) throw new ApiError(meta.errors.invalidParam); + query.andWhere('note.userId IN (:...users)', { users: ps.users }); + } + + query + .innerJoinAndSelect(Event, 'event', 'event.noteId = note.id') + .innerJoinAndSelect('note.user', 'user'); + + if (ps.filters) { + const filters: Record = ps.filters; + + Object.keys(filters).forEach(f => { + const matches = filters[f].filter(x => x !== null); + if (matches.length < 1) throw new ApiError(meta.errors.invalidParam); + query.andWhere(new Brackets((qb) => { + qb.where('event.metadata ->> :key IN (:...values)', { key: f, values: filters[f].filter(x => x !== null) }); + if (filters[f].filter(x => x === null).length > 0) { + qb.orWhere('event.metadata ->> :key IS NULL', { key: f }); + } + })); + }); + } + + if (ps.sinceDate && ps.untilDate && ps.sinceDate > ps.untilDate) throw new ApiError(meta.errors.invalidParam); + const sinceDate = ps.sinceDate ? new Date(ps.sinceDate) : new Date(); + query.andWhere('event.start > :sinceDate', { sinceDate: sinceDate }) + .andWhere('(event.end IS NULL OR event.end > :sinceDate)', { sinceDate: sinceDate }); + if (ps.untilDate) { + query.andWhere('event.start < :untilDate', { untilDate: new Date(ps.untilDate) }); + } + + if (ps.sortBy === 'createdAt') { + query.orderBy('note.createdAt', 'ASC'); + } else { + query.orderBy('event.start', 'ASC'); + } + + this.queryService.generateVisibilityQuery(query, me); + if (me) this.queryService.generateMutedUserQuery(query, me); + if (me) this.queryService.generateBlockedUserQuery(query, me); + + const notes = await query.take(ps.limit).getMany(); + + return await this.noteEntityService.packMany(notes, me); + }); + } +} diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 67d12000b8..1936d8fb50 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -1757,971 +1757,991 @@ export type Endpoints = { }; res: null; }; - 'notes/reactions': { - req: { - noteId: Note['id']; - type?: string | null; - limit?: number; + 'notes/events/search': { + req: { + sinceId?: Note['id']; + untilId?: Note['id']; + limit?: number; + offset?: number; + host?: string; + users?: User['id'][]; + sinceDate?: number; + untilDate?: number; + filters?: Record; + sortBy?: 'startDate' | 'createAt' + res: Note[]; + }; + 'notes/reactions': { + req: { + noteId: Note['id']; + type?: string | null; + limit?: number; + }; + res: NoteReaction[]; }; - res: NoteReaction[]; - }; - 'notes/reactions/create': { - req: { - noteId: Note['id']; - reaction: string; + 'notes/reactions/create': { + req: { + noteId: Note['id']; + reaction: string; + }; + res: null; }; - res: null; - }; - 'notes/reactions/delete': { - req: { - noteId: Note['id']; + 'notes/reactions/delete': { + req: { + noteId: Note['id']; + }; + res: null; }; - res: null; - }; - 'notes/renotes': { - req: { - limit?: number; - sinceId?: Note['id']; - untilId?: Note['id']; - noteId: Note['id']; + 'notes/renotes': { + req: { + limit?: number; + sinceId?: Note['id']; + untilId?: Note['id']; + noteId: Note['id']; + }; + res: Note[]; }; - res: Note[]; - }; - 'notes/replies': { - req: { - limit?: number; - sinceId?: Note['id']; - untilId?: Note['id']; - noteId: Note['id']; + 'notes/replies': { + req: { + limit?: number; + sinceId?: Note['id']; + untilId?: Note['id']; + noteId: Note['id']; + }; + res: Note[]; }; - res: Note[]; - }; - 'notes/search-by-tag': { - req: TODO; - res: TODO; - }; - 'notes/search': { - req: TODO; - res: TODO; - }; - 'notes/show': { - req: { - noteId: Note['id']; + 'notes/search-by-tag': { + req: TODO; + res: TODO; }; - res: Note; - }; - 'notes/state': { - req: TODO; - res: TODO; - }; - 'notes/timeline': { - req: { - limit?: number; - sinceId?: Note['id']; - untilId?: Note['id']; - sinceDate?: number; - untilDate?: number; + 'notes/search': { + req: TODO; + res: TODO; }; - res: Note[]; - }; - 'notes/unrenote': { - req: { - noteId: Note['id']; + 'notes/show': { + req: { + noteId: Note['id']; + }; + res: Note; }; - res: null; - }; - 'notes/user-list-timeline': { - req: { - listId: UserList['id']; - limit?: number; - sinceId?: Note['id']; - untilId?: Note['id']; - sinceDate?: number; - untilDate?: number; + 'notes/state': { + req: TODO; + res: TODO; }; - res: Note[]; - }; - 'notes/watching/create': { - req: TODO; - res: TODO; - }; - 'notes/watching/delete': { - req: { - noteId: Note['id']; + 'notes/timeline': { + req: { + limit?: number; + sinceId?: Note['id']; + untilId?: Note['id']; + sinceDate?: number; + untilDate?: number; + }; + res: Note[]; }; - res: null; - }; - 'notifications/create': { - req: { - body: string; - header?: string | null; - icon?: string | null; + 'notes/unrenote': { + req: { + noteId: Note['id']; + }; + res: null; }; - res: null; - }; - 'notifications/mark-all-as-read': { - req: NoParams; - res: null; - }; - 'page-push': { - req: { - pageId: Page['id']; - event: string; - var?: any; + 'notes/user-list-timeline': { + req: { + listId: UserList['id']; + limit?: number; + sinceId?: Note['id']; + untilId?: Note['id']; + sinceDate?: number; + untilDate?: number; + }; + res: Note[]; }; - res: null; - }; - 'pages/create': { - req: TODO; - res: Page; - }; - 'pages/delete': { - req: { - pageId: Page['id']; + 'notes/watching/create': { + req: TODO; + res: TODO; }; - res: null; - }; - 'pages/featured': { - req: NoParams; - res: Page[]; - }; - 'pages/like': { - req: { - pageId: Page['id']; + 'notes/watching/delete': { + req: { + noteId: Note['id']; + }; + res: null; }; - res: null; - }; - 'pages/show': { - req: { - pageId?: Page['id']; - name?: string; - username?: string; + 'notifications/create': { + req: { + body: string; + header?: string | null; + icon?: string | null; + }; + res: null; }; - res: Page; - }; - 'pages/unlike': { - req: { - pageId: Page['id']; + 'notifications/mark-all-as-read': { + req: NoParams; + res: null; }; - res: null; - }; - 'pages/update': { - req: TODO; - res: null; - }; - 'ping': { - req: NoParams; - res: { - pong: number; + 'page-push': { + req: { + pageId: Page['id']; + event: string; + var?: any; + }; + res: null; }; - }; - 'pinned-users': { - req: TODO; - res: TODO; - }; - 'promo/read': { - req: TODO; - res: TODO; - }; - 'request-reset-password': { - req: { - username: string; - email: string; + 'pages/create': { + req: TODO; + res: Page; }; - res: null; - }; - 'reset-password': { - req: { - token: string; - password: string; + 'pages/delete': { + req: { + pageId: Page['id']; + }; + res: null; }; - res: null; - }; - 'room/show': { - req: TODO; - res: TODO; - }; - 'room/update': { - req: TODO; - res: TODO; - }; - 'stats': { - req: NoParams; - res: Stats; - }; - 'server-info': { - req: NoParams; - res: ServerInfo; - }; - 'sw/register': { - req: TODO; - res: TODO; - }; - 'username/available': { - req: { - username: string; + 'pages/featured': { + req: NoParams; + res: Page[]; }; - res: { - available: boolean; + 'pages/like': { + req: { + pageId: Page['id']; + }; + res: null; }; - }; - 'users': { - req: { - limit?: number; - offset?: number; - sort?: UserSorting; - origin?: OriginType; + 'pages/show': { + req: { + pageId?: Page['id']; + name?: string; + username?: string; + }; + res: Page; }; - res: User[]; - }; - 'users/clips': { - req: TODO; - res: TODO; - }; - 'users/followers': { - req: { - userId?: User['id']; - username?: User['username']; - host?: User['host'] | null; - limit?: number; - sinceId?: Following['id']; - untilId?: Following['id']; + 'pages/unlike': { + req: { + pageId: Page['id']; + }; + res: null; }; - res: FollowingFollowerPopulated[]; - }; - 'users/following': { - req: { - userId?: User['id']; - username?: User['username']; - host?: User['host'] | null; - limit?: number; - sinceId?: Following['id']; - untilId?: Following['id']; + 'pages/update': { + req: TODO; + res: null; }; - res: FollowingFolloweePopulated[]; - }; - 'users/gallery/posts': { - req: TODO; - res: TODO; - }; - 'users/get-frequently-replied-users': { - req: TODO; - res: TODO; - }; - 'users/groups/create': { - req: TODO; - res: TODO; - }; - 'users/groups/delete': { - req: { - groupId: UserGroup['id']; - }; - res: null; - }; - 'users/groups/invitations/accept': { - req: TODO; - res: TODO; - }; - 'users/groups/invitations/reject': { - req: TODO; - res: TODO; - }; - 'users/groups/invite': { - req: TODO; - res: TODO; - }; - 'users/groups/joined': { - req: TODO; - res: TODO; - }; - 'users/groups/owned': { - req: TODO; - res: TODO; - }; - 'users/groups/pull': { - req: TODO; - res: TODO; - }; - 'users/groups/show': { - req: TODO; - res: TODO; - }; - 'users/groups/transfer': { - req: TODO; - res: TODO; - }; - 'users/groups/update': { - req: TODO; - res: TODO; - }; - 'users/lists/create': { - req: { - name: string; - }; - res: UserList; - }; - 'users/lists/delete': { - req: { - listId: UserList['id']; - }; - res: null; - }; - 'users/lists/list': { - req: NoParams; - res: UserList[]; - }; - 'users/lists/pull': { - req: { - listId: UserList['id']; - userId: User['id']; - }; - res: null; - }; - 'users/lists/push': { - req: { - listId: UserList['id']; - userId: User['id']; - }; - res: null; - }; - 'users/lists/show': { - req: { - listId: UserList['id']; - }; - res: UserList; - }; - 'users/lists/update': { - req: { - listId: UserList['id']; - name: string; - }; - res: UserList; - }; - 'users/notes': { - req: { - userId: User['id']; - limit?: number; - sinceId?: Note['id']; - untilId?: Note['id']; - sinceDate?: number; - untilDate?: number; - }; - res: Note[]; - }; - 'users/pages': { - req: TODO; - res: TODO; - }; - 'users/recommendation': { - req: TODO; - res: TODO; - }; - 'users/relation': { - req: TODO; - res: TODO; - }; - 'users/report-abuse': { - req: TODO; - res: TODO; - }; - 'users/search-by-username-and-host': { - req: TODO; - res: TODO; - }; - 'users/search': { - req: TODO; - res: TODO; - }; - 'users/show': { - req: ShowUserReq | { - userIds: User['id'][]; - }; - res: { - $switch: { - $cases: [ - [ - { - userIds: User['id'][]; - }, - UserDetailed[] - ] - ]; - $default: UserDetailed; + 'ping': { + req: NoParams; + res: { + pong: number; }; }; + 'pinned-users': { + req: TODO; + res: TODO; + }; + 'promo/read': { + req: TODO; + res: TODO; + }; + 'request-reset-password': { + req: { + username: string; + email: string; + }; + res: null; + }; + 'reset-password': { + req: { + token: string; + password: string; + }; + res: null; + }; + 'room/show': { + req: TODO; + res: TODO; + }; + 'room/update': { + req: TODO; + res: TODO; + }; + 'stats': { + req: NoParams; + res: Stats; + }; + 'server-info': { + req: NoParams; + res: ServerInfo; + }; + 'sw/register': { + req: TODO; + res: TODO; + }; + 'username/available': { + req: { + username: string; + }; + res: { + available: boolean; + }; + }; + 'users': { + req: { + limit?: number; + offset?: number; + sort?: UserSorting; + origin?: OriginType; + }; + res: User[]; + }; + 'users/clips': { + req: TODO; + res: TODO; + }; + 'users/followers': { + req: { + userId?: User['id']; + username?: User['username']; + host?: User['host'] | null; + limit?: number; + sinceId?: Following['id']; + untilId?: Following['id']; + }; + res: FollowingFollowerPopulated[]; + }; + 'users/following': { + req: { + userId?: User['id']; + username?: User['username']; + host?: User['host'] | null; + limit?: number; + sinceId?: Following['id']; + untilId?: Following['id']; + }; + res: FollowingFolloweePopulated[]; + }; + 'users/gallery/posts': { + req: TODO; + res: TODO; + }; + 'users/get-frequently-replied-users': { + req: TODO; + res: TODO; + }; + 'users/groups/create': { + req: TODO; + res: TODO; + }; + 'users/groups/delete': { + req: { + groupId: UserGroup['id']; + }; + res: null; + }; + 'users/groups/invitations/accept': { + req: TODO; + res: TODO; + }; + 'users/groups/invitations/reject': { + req: TODO; + res: TODO; + }; + 'users/groups/invite': { + req: TODO; + res: TODO; + }; + 'users/groups/joined': { + req: TODO; + res: TODO; + }; + 'users/groups/owned': { + req: TODO; + res: TODO; + }; + 'users/groups/pull': { + req: TODO; + res: TODO; + }; + 'users/groups/show': { + req: TODO; + res: TODO; + }; + 'users/groups/transfer': { + req: TODO; + res: TODO; + }; + 'users/groups/update': { + req: TODO; + res: TODO; + }; + 'users/lists/create': { + req: { + name: string; + }; + res: UserList; + }; + 'users/lists/delete': { + req: { + listId: UserList['id']; + }; + res: null; + }; + 'users/lists/list': { + req: NoParams; + res: UserList[]; + }; + 'users/lists/pull': { + req: { + listId: UserList['id']; + userId: User['id']; + }; + res: null; + }; + 'users/lists/push': { + req: { + listId: UserList['id']; + userId: User['id']; + }; + res: null; + }; + 'users/lists/show': { + req: { + listId: UserList['id']; + }; + res: UserList; + }; + 'users/lists/update': { + req: { + listId: UserList['id']; + name: string; + }; + res: UserList; + }; + 'users/notes': { + req: { + userId: User['id']; + limit?: number; + sinceId?: Note['id']; + untilId?: Note['id']; + sinceDate?: number; + untilDate?: number; + }; + res: Note[]; + }; + 'users/pages': { + req: TODO; + res: TODO; + }; + 'users/recommendation': { + req: TODO; + res: TODO; + }; + 'users/relation': { + req: TODO; + res: TODO; + }; + 'users/report-abuse': { + req: TODO; + res: TODO; + }; + 'users/search-by-username-and-host': { + req: TODO; + res: TODO; + }; + 'users/search': { + req: TODO; + res: TODO; + }; + 'users/show': { + req: ShowUserReq | { + userIds: User['id'][]; + }; + res: { + $switch: { + $cases: [ + [ + { + userIds: User['id'][]; + }, + UserDetailed[] + ] + ]; + $default: UserDetailed; + }; + }; + }; + 'users/stats': { + req: TODO; + res: TODO; + }; }; - 'users/stats': { - req: TODO; - res: TODO; - }; -}; -declare namespace entities { - export { - ID, - DateString, - User, - UserLite, - UserDetailed, - UserGroup, - UserList, - MeDetailed, - DriveFile, - DriveFolder, - GalleryPost, - Note, - NoteReaction, - Notification_2 as Notification, - MessagingMessage, - CustomEmoji, - LiteInstanceMetadata, - DetailedInstanceMetadata, - InstanceMetadata, - ServerInfo, - Stats, - Page, - PageEvent, - Announcement, - Antenna, - App, - AuthSession, - Ad, - Clip, - NoteFavorite, - FollowRequest, - Channel, - Following, - FollowingFolloweePopulated, - FollowingFollowerPopulated, - Blocking, - Instance, - Signin, - UserSorting, - OriginType + declare namespace entities { + export { + ID, + DateString, + User, + UserLite, + UserDetailed, + UserGroup, + UserList, + MeDetailed, + DriveFile, + DriveFolder, + GalleryPost, + Note, + NoteReaction, + Notification_2 as Notification, + MessagingMessage, + CustomEmoji, + LiteInstanceMetadata, + DetailedInstanceMetadata, + InstanceMetadata, + ServerInfo, + Stats, + Page, + PageEvent, + Announcement, + Antenna, + App, + AuthSession, + Ad, + Clip, + NoteFavorite, + FollowRequest, + Channel, + Following, + FollowingFolloweePopulated, + FollowingFollowerPopulated, + Blocking, + Instance, + Signin, + UserSorting, + OriginType + } } -} -export { entities } + export { entities } -// @public (undocumented) -type FetchLike = (input: string, init?: { - method?: string; - body?: string; - credentials?: RequestCredentials; - cache?: RequestCache; - headers: { - [key in string]: string; - }; -}) => Promise<{ - status: number; - json(): Promise; -}>; + // @public (undocumented) + type FetchLike = (input: string, init?: { + method?: string; + body?: string; + credentials?: RequestCredentials; + cache?: RequestCache; + headers: { + [key in string]: string; + }; + }) => Promise<{ + status: number; + json(): Promise; + }>; -// @public (undocumented) -export const ffVisibility: readonly ["public", "followers", "private"]; + // @public (undocumented) + export const ffVisibility: readonly ["public", "followers", "private"]; -// @public (undocumented) -type Following = { - id: ID; - createdAt: DateString; - followerId: User['id']; - followeeId: User['id']; -}; - -// @public (undocumented) -type FollowingFolloweePopulated = Following & { - followee: UserDetailed; -}; - -// @public (undocumented) -type FollowingFollowerPopulated = Following & { - follower: UserDetailed; -}; - -// @public (undocumented) -type FollowRequest = { - id: ID; - follower: User; - followee: User; -}; - -// @public (undocumented) -type GalleryPost = { - id: ID; - createdAt: DateString; - updatedAt: DateString; - userId: User['id']; - user: User; - title: string; - description: string | null; - fileIds: DriveFile['id'][]; - files: DriveFile[]; - isSensitive: boolean; - likedCount: number; - isLiked?: boolean; -}; - -// @public (undocumented) -type ID = string; - -// @public (undocumented) -type Instance = { - id: ID; - caughtAt: DateString; - host: string; - usersCount: number; - notesCount: number; - followingCount: number; - followersCount: number; - driveUsage: number; - driveFiles: number; - latestRequestSentAt: DateString | null; - latestStatus: number | null; - latestRequestReceivedAt: DateString | null; - lastCommunicatedAt: DateString; - isNotResponding: boolean; - isSuspended: boolean; - softwareName: string | null; - softwareVersion: string | null; - openRegistrations: boolean | null; - name: string | null; - description: string | null; - maintainerName: string | null; - maintainerEmail: string | null; - iconUrl: string | null; - faviconUrl: string | null; - themeColor: string | null; - infoUpdatedAt: DateString | null; -}; - -// @public (undocumented) -type InstanceMetadata = LiteInstanceMetadata | DetailedInstanceMetadata; - -// @public (undocumented) -function isAPIError(reason: any): reason is APIError; - -// @public (undocumented) -type LiteInstanceMetadata = { - maintainerName: string | null; - maintainerEmail: string | null; - version: string; - name: string | null; - uri: string; - description: string | null; - langs: string[]; - tosUrl: string | null; - repositoryUrl: string; - feedbackUrl: string; - disableRegistration: boolean; - disableLocalTimeline: boolean; - disableGlobalTimeline: boolean; - driveCapacityPerLocalUserMb: number; - driveCapacityPerRemoteUserMb: number; - emailRequiredForSignup: boolean; - enableHcaptcha: boolean; - hcaptchaSiteKey: string | null; - enableRecaptcha: boolean; - recaptchaSiteKey: string | null; - enableTurnstile: boolean; - turnstileSiteKey: string | null; - swPublickey: string | null; - themeColor: string | null; - mascotImageUrl: string | null; - bannerUrl: string | null; - errorImageUrl: string | null; - iconUrl: string | null; - backgroundImageUrl: string | null; - logoImageUrl: string | null; - maxNoteTextLength: number; - enableEmail: boolean; - enableTwitterIntegration: boolean; - enableGithubIntegration: boolean; - enableDiscordIntegration: boolean; - enableServiceWorker: boolean; - emojis: CustomEmoji[]; - defaultDarkTheme: string | null; - defaultLightTheme: string | null; - ads: { + // @public (undocumented) + type Following = { id: ID; - ratio: number; - place: string; - url: string; - imageUrl: string; - }[]; - translatorAvailable: boolean; -}; + createdAt: DateString; + followerId: User['id']; + followeeId: User['id']; + }; -// @public (undocumented) -type MeDetailed = UserDetailed & { - avatarId: DriveFile['id']; - bannerId: DriveFile['id']; - autoAcceptFollowed: boolean; - alwaysMarkNsfw: boolean; - carefulBot: boolean; - emailNotificationTypes: string[]; - hasPendingReceivedFollowRequest: boolean; - hasUnreadAnnouncement: boolean; - hasUnreadAntenna: boolean; - hasUnreadMentions: boolean; - hasUnreadMessagingMessage: boolean; - hasUnreadNotification: boolean; - hasUnreadSpecifiedNotes: boolean; - hideOnlineStatus: boolean; - injectFeaturedNote: boolean; - integrations: Record; - isDeleted: boolean; - isExplorable: boolean; - mutedWords: string[][]; - mutingNotificationTypes: string[]; - noCrawle: boolean; - receiveAnnouncementEmail: boolean; - usePasswordLessLogin: boolean; - [other: string]: any; -}; + // @public (undocumented) + type FollowingFolloweePopulated = Following & { + followee: UserDetailed; + }; -// @public (undocumented) -type MessagingMessage = { - id: ID; - createdAt: DateString; - file: DriveFile | null; - fileId: DriveFile['id'] | null; - isRead: boolean; - reads: User['id'][]; - text: string | null; - user: User; - userId: User['id']; - recipient?: User | null; - recipientId: User['id'] | null; - group?: UserGroup | null; - groupId: UserGroup['id'] | null; -}; + // @public (undocumented) + type FollowingFollowerPopulated = Following & { + follower: UserDetailed; + }; -// @public (undocumented) -export const mutedNoteReasons: readonly ["word", "manual", "spam", "other"]; + // @public (undocumented) + type FollowRequest = { + id: ID; + follower: User; + followee: User; + }; -// @public (undocumented) -type Note = { - id: ID; - createdAt: DateString; - text: string | null; - cw: string | null; - user: User; - userId: User['id']; - reply?: Note; - replyId: Note['id']; - renote?: Note; - renoteId: Note['id']; - files: DriveFile[]; - fileIds: DriveFile['id'][]; - visibility: 'public' | 'home' | 'followers' | 'specified'; - visibleUserIds?: User['id'][]; - localOnly?: boolean; - myReaction?: string; - reactions: Record; - renoteCount: number; - repliesCount: number; - poll?: { - expiresAt: DateString | null; - multiple: boolean; - choices: { - isVoted: boolean; - text: string; - votes: number; + // @public (undocumented) + type GalleryPost = { + id: ID; + createdAt: DateString; + updatedAt: DateString; + userId: User['id']; + user: User; + title: string; + description: string | null; + fileIds: DriveFile['id'][]; + files: DriveFile[]; + isSensitive: boolean; + likedCount: number; + isLiked?: boolean; + }; + + // @public (undocumented) + type ID = string; + + // @public (undocumented) + type Instance = { + id: ID; + caughtAt: DateString; + host: string; + usersCount: number; + notesCount: number; + followingCount: number; + followersCount: number; + driveUsage: number; + driveFiles: number; + latestRequestSentAt: DateString | null; + latestStatus: number | null; + latestRequestReceivedAt: DateString | null; + lastCommunicatedAt: DateString; + isNotResponding: boolean; + isSuspended: boolean; + softwareName: string | null; + softwareVersion: string | null; + openRegistrations: boolean | null; + name: string | null; + description: string | null; + maintainerName: string | null; + maintainerEmail: string | null; + iconUrl: string | null; + faviconUrl: string | null; + themeColor: string | null; + infoUpdatedAt: DateString | null; + }; + + // @public (undocumented) + type InstanceMetadata = LiteInstanceMetadata | DetailedInstanceMetadata; + + // @public (undocumented) + function isAPIError(reason: any): reason is APIError; + + // @public (undocumented) + type LiteInstanceMetadata = { + maintainerName: string | null; + maintainerEmail: string | null; + version: string; + name: string | null; + uri: string; + description: string | null; + langs: string[]; + tosUrl: string | null; + repositoryUrl: string; + feedbackUrl: string; + disableRegistration: boolean; + disableLocalTimeline: boolean; + disableGlobalTimeline: boolean; + driveCapacityPerLocalUserMb: number; + driveCapacityPerRemoteUserMb: number; + emailRequiredForSignup: boolean; + enableHcaptcha: boolean; + hcaptchaSiteKey: string | null; + enableRecaptcha: boolean; + recaptchaSiteKey: string | null; + enableTurnstile: boolean; + turnstileSiteKey: string | null; + swPublickey: string | null; + themeColor: string | null; + mascotImageUrl: string | null; + bannerUrl: string | null; + errorImageUrl: string | null; + iconUrl: string | null; + backgroundImageUrl: string | null; + logoImageUrl: string | null; + maxNoteTextLength: number; + enableEmail: boolean; + enableTwitterIntegration: boolean; + enableGithubIntegration: boolean; + enableDiscordIntegration: boolean; + enableServiceWorker: boolean; + emojis: CustomEmoji[]; + defaultDarkTheme: string | null; + defaultLightTheme: string | null; + ads: { + id: ID; + ratio: number; + place: string; + url: string; + imageUrl: string; }[]; + translatorAvailable: boolean; }; - emojis: { - name: string; - url: string; - }[]; - uri?: string; - url?: string; - isHidden?: boolean; -}; -// @public (undocumented) -type NoteFavorite = { - id: ID; - createdAt: DateString; - noteId: Note['id']; - note: Note; -}; - -// @public (undocumented) -type NoteReaction = { - id: ID; - createdAt: DateString; - user: UserLite; - type: string; -}; - -// @public (undocumented) -export const noteVisibilities: readonly ["public", "home", "followers", "specified"]; - -// @public (undocumented) -type Notification_2 = { - id: ID; - createdAt: DateString; - isRead: boolean; -} & ({ - type: 'reaction'; - reaction: string; - user: User; - userId: User['id']; - note: Note; -} | { - type: 'reply'; - user: User; - userId: User['id']; - note: Note; -} | { - type: 'renote'; - user: User; - userId: User['id']; - note: Note; -} | { - type: 'quote'; - user: User; - userId: User['id']; - note: Note; -} | { - type: 'mention'; - user: User; - userId: User['id']; - note: Note; -} | { - type: 'pollVote'; - user: User; - userId: User['id']; - note: Note; -} | { - type: 'follow'; - user: User; - userId: User['id']; -} | { - type: 'followRequestAccepted'; - user: User; - userId: User['id']; -} | { - type: 'receiveFollowRequest'; - user: User; - userId: User['id']; -} | { - type: 'groupInvited'; - invitation: UserGroup; - user: User; - userId: User['id']; -} | { - type: 'app'; - header?: string | null; - body: string; - icon?: string | null; -}); - -// @public (undocumented) -export const notificationTypes: readonly ["follow", "mention", "reply", "renote", "quote", "reaction", "pollVote", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app"]; - -// @public (undocumented) -type OriginType = 'combined' | 'local' | 'remote'; - -// @public (undocumented) -type Page = { - id: ID; - createdAt: DateString; - updatedAt: DateString; - userId: User['id']; - user: User; - content: Record[]; - variables: Record[]; - title: string; - name: string; - summary: string | null; - hideTitleWhenPinned: boolean; - alignCenter: boolean; - font: string; - script: string; - eyeCatchingImageId: DriveFile['id'] | null; - eyeCatchingImage: DriveFile | null; - attachedFiles: any; - likedCount: number; - isLiked?: boolean; -}; - -// @public (undocumented) -type PageEvent = { - pageId: Page['id']; - event: string; - var: any; - userId: User['id']; - user: User; -}; - -// @public (undocumented) -export const permissions: string[]; - -// @public (undocumented) -type ServerInfo = { - machine: string; - cpu: { - model: string; - cores: number; + // @public (undocumented) + type MeDetailed = UserDetailed & { + avatarId: DriveFile['id']; + bannerId: DriveFile['id']; + autoAcceptFollowed: boolean; + alwaysMarkNsfw: boolean; + carefulBot: boolean; + emailNotificationTypes: string[]; + hasPendingReceivedFollowRequest: boolean; + hasUnreadAnnouncement: boolean; + hasUnreadAntenna: boolean; + hasUnreadMentions: boolean; + hasUnreadMessagingMessage: boolean; + hasUnreadNotification: boolean; + hasUnreadSpecifiedNotes: boolean; + hideOnlineStatus: boolean; + injectFeaturedNote: boolean; + integrations: Record; + isDeleted: boolean; + isExplorable: boolean; + mutedWords: string[][]; + mutingNotificationTypes: string[]; + noCrawle: boolean; + receiveAnnouncementEmail: boolean; + usePasswordLessLogin: boolean; + [other: string]: any; }; - mem: { - total: number; + + // @public (undocumented) + type MessagingMessage = { + id: ID; + createdAt: DateString; + file: DriveFile | null; + fileId: DriveFile['id'] | null; + isRead: boolean; + reads: User['id'][]; + text: string | null; + user: User; + userId: User['id']; + recipient?: User | null; + recipientId: User['id'] | null; + group?: UserGroup | null; + groupId: UserGroup['id'] | null; }; - fs: { - total: number; - used: number; + + // @public (undocumented) + export const mutedNoteReasons: readonly ["word", "manual", "spam", "other"]; + + // @public (undocumented) + type Note = { + id: ID; + createdAt: DateString; + text: string | null; + cw: string | null; + user: User; + userId: User['id']; + reply?: Note; + replyId: Note['id']; + renote?: Note; + renoteId: Note['id']; + event?: { + title: string; + start: DateString; + end: DateString | null; + metadata: Record; + }; + files: DriveFile[]; + fileIds: DriveFile['id'][]; + visibility: 'public' | 'home' | 'followers' | 'specified'; + visibleUserIds?: User['id'][]; + localOnly?: boolean; + myReaction?: string; + reactions: Record; + renoteCount: number; + repliesCount: number; + poll?: { + expiresAt: DateString | null; + multiple: boolean; + choices: { + isVoted: boolean; + text: string; + votes: number; + }[]; + }; + emojis: { + name: string; + url: string; + }[]; + uri?: string; + url?: string; + isHidden?: boolean; }; -}; -// @public (undocumented) -type Signin = { - id: ID; - createdAt: DateString; - ip: string; - headers: Record; - success: boolean; -}; + // @public (undocumented) + type NoteFavorite = { + id: ID; + createdAt: DateString; + noteId: Note['id']; + note: Note; + }; -// @public (undocumented) -type Stats = { - notesCount: number; - originalNotesCount: number; - usersCount: number; - originalUsersCount: number; - instances: number; - driveUsageLocal: number; - driveUsageRemote: number; -}; + // @public (undocumented) + type NoteReaction = { + id: ID; + createdAt: DateString; + user: UserLite; + type: string; + }; -// Warning: (ae-forgotten-export) The symbol "StreamEvents" needs to be exported by the entry point index.d.ts -// -// @public (undocumented) -export class Stream extends EventEmitter { - constructor(origin: string, user: { - token: string; - } | null, options?: { - WebSocket?: any; + // @public (undocumented) + export const noteVisibilities: readonly ["public", "home", "followers", "specified"]; + + // @public (undocumented) + type Notification_2 = { + id: ID; + createdAt: DateString; + isRead: boolean; + } & ({ + type: 'reaction'; + reaction: string; + user: User; + userId: User['id']; + note: Note; + } | { + type: 'reply'; + user: User; + userId: User['id']; + note: Note; + } | { + type: 'renote'; + user: User; + userId: User['id']; + note: Note; + } | { + type: 'quote'; + user: User; + userId: User['id']; + note: Note; + } | { + type: 'mention'; + user: User; + userId: User['id']; + note: Note; + } | { + type: 'pollVote'; + user: User; + userId: User['id']; + note: Note; + } | { + type: 'follow'; + user: User; + userId: User['id']; + } | { + type: 'followRequestAccepted'; + user: User; + userId: User['id']; + } | { + type: 'receiveFollowRequest'; + user: User; + userId: User['id']; + } | { + type: 'groupInvited'; + invitation: UserGroup; + user: User; + userId: User['id']; + } | { + type: 'app'; + header?: string | null; + body: string; + icon?: string | null; }); - // (undocumented) - close(): void; - // Warning: (ae-forgotten-export) The symbol "NonSharedConnection" needs to be exported by the entry point index.d.ts - // - // (undocumented) - disconnectToChannel(connection: NonSharedConnection): void; - // Warning: (ae-forgotten-export) The symbol "SharedConnection" needs to be exported by the entry point index.d.ts - // - // (undocumented) - removeSharedConnection(connection: SharedConnection): void; - // Warning: (ae-forgotten-export) The symbol "Pool" needs to be exported by the entry point index.d.ts - // - // (undocumented) - removeSharedConnectionPool(pool: Pool): void; - // (undocumented) - send(typeOrPayload: string): void; - // (undocumented) - send(typeOrPayload: string, payload: any): void; - // (undocumented) - send(typeOrPayload: Record | any[]): void; - // (undocumented) - state: 'initializing' | 'reconnecting' | 'connected'; - // (undocumented) - useChannel(channel: C, params?: Channels[C]['params'], name?: string): ChannelConnection; -} -// @public (undocumented) -type User = UserLite | UserDetailed; + // @public (undocumented) + export const notificationTypes: readonly ["follow", "mention", "reply", "renote", "quote", "reaction", "pollVote", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app"]; -// @public (undocumented) -type UserDetailed = UserLite & { - bannerBlurhash: string | null; - bannerColor: string | null; - bannerUrl: string | null; - birthday: string | null; - createdAt: DateString; - description: string | null; - ffVisibility: 'public' | 'followers' | 'private'; - fields: { + // @public (undocumented) + type OriginType = 'combined' | 'local' | 'remote'; + + // @public (undocumented) + type Page = { + id: ID; + createdAt: DateString; + updatedAt: DateString; + userId: User['id']; + user: User; + content: Record[]; + variables: Record[]; + title: string; name: string; - value: string; - }[]; - followersCount: number; - followingCount: number; - hasPendingFollowRequestFromYou: boolean; - hasPendingFollowRequestToYou: boolean; - isAdmin: boolean; - isBlocked: boolean; - isBlocking: boolean; - isBot: boolean; - isCat: boolean; - isFollowed: boolean; - isFollowing: boolean; - isLocked: boolean; - isModerator: boolean; - isMuted: boolean; - isSilenced: boolean; - isSuspended: boolean; - lang: string | null; - lastFetchedAt?: DateString; - location: string | null; - notesCount: number; - pinnedNoteIds: ID[]; - pinnedNotes: Note[]; - pinnedPage: Page | null; - pinnedPageId: string | null; - publicReactions: boolean; - securityKeys: boolean; - twoFactorEnabled: boolean; - updatedAt: DateString | null; - uri: string | null; - url: string | null; -}; - -// @public (undocumented) -type UserGroup = TODO_2; - -// @public (undocumented) -type UserList = { - id: ID; - createdAt: DateString; - name: string; - userIds: User['id'][]; -}; - -// @public (undocumented) -type UserLite = { - id: ID; - username: string; - host: string | null; - name: string; - onlineStatus: 'online' | 'active' | 'offline' | 'unknown'; - avatarUrl: string; - avatarBlurhash: string; - alsoKnownAs: string[]; - movedToUri: any; - emojis: { - name: string; - url: string; - }[]; - instance?: { - name: Instance['name']; - softwareName: Instance['softwareName']; - softwareVersion: Instance['softwareVersion']; - iconUrl: Instance['iconUrl']; - faviconUrl: Instance['faviconUrl']; - themeColor: Instance['themeColor']; + summary: string | null; + hideTitleWhenPinned: boolean; + alignCenter: boolean; + font: string; + script: string; + eyeCatchingImageId: DriveFile['id'] | null; + eyeCatchingImage: DriveFile | null; + attachedFiles: any; + likedCount: number; + isLiked?: boolean; }; -}; -// @public (undocumented) -type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+updatedAt' | '-updatedAt'; + // @public (undocumented) + type PageEvent = { + pageId: Page['id']; + event: string; + var: any; + userId: User['id']; + user: User; + }; -// Warnings were encountered during analysis: -// -// src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts -// src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts -// src/api.types.ts:596:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts -// src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts + // @public (undocumented) + export const permissions: string[]; -// (No @packageDocumentation comment for this package) + // @public (undocumented) + type ServerInfo = { + machine: string; + cpu: { + model: string; + cores: number; + }; + mem: { + total: number; + }; + fs: { + total: number; + used: number; + }; + }; -``` + // @public (undocumented) + type Signin = { + id: ID; + createdAt: DateString; + ip: string; + headers: Record; + success: boolean; + }; + + // @public (undocumented) + type Stats = { + notesCount: number; + originalNotesCount: number; + usersCount: number; + originalUsersCount: number; + instances: number; + driveUsageLocal: number; + driveUsageRemote: number; + }; + + // Warning: (ae-forgotten-export) The symbol "StreamEvents" needs to be exported by the entry point index.d.ts + // + // @public (undocumented) + export class Stream extends EventEmitter { + constructor(origin: string, user: { + token: string; + } | null, options?: { + WebSocket?: any; + }); + // (undocumented) + close(): void; + // Warning: (ae-forgotten-export) The symbol "NonSharedConnection" needs to be exported by the entry point index.d.ts + // + // (undocumented) + disconnectToChannel(connection: NonSharedConnection): void; + // Warning: (ae-forgotten-export) The symbol "SharedConnection" needs to be exported by the entry point index.d.ts + // + // (undocumented) + removeSharedConnection(connection: SharedConnection): void; + // Warning: (ae-forgotten-export) The symbol "Pool" needs to be exported by the entry point index.d.ts + // + // (undocumented) + removeSharedConnectionPool(pool: Pool): void; + // (undocumented) + send(typeOrPayload: string): void; + // (undocumented) + send(typeOrPayload: string, payload: any): void; + // (undocumented) + send(typeOrPayload: Record | any[]): void; + // (undocumented) + state: 'initializing' | 'reconnecting' | 'connected'; + // (undocumented) + useChannel(channel: C, params?: Channels[C]['params'], name?: string): ChannelConnection; + } + + // @public (undocumented) + type User = UserLite | UserDetailed; + + // @public (undocumented) + type UserDetailed = UserLite & { + bannerBlurhash: string | null; + bannerColor: string | null; + bannerUrl: string | null; + birthday: string | null; + createdAt: DateString; + description: string | null; + ffVisibility: 'public' | 'followers' | 'private'; + fields: { + name: string; + value: string; + }[]; + followersCount: number; + followingCount: number; + hasPendingFollowRequestFromYou: boolean; + hasPendingFollowRequestToYou: boolean; + isAdmin: boolean; + isBlocked: boolean; + isBlocking: boolean; + isBot: boolean; + isCat: boolean; + isFollowed: boolean; + isFollowing: boolean; + isLocked: boolean; + isModerator: boolean; + isMuted: boolean; + isSilenced: boolean; + isSuspended: boolean; + lang: string | null; + lastFetchedAt?: DateString; + location: string | null; + notesCount: number; + pinnedNoteIds: ID[]; + pinnedNotes: Note[]; + pinnedPage: Page | null; + pinnedPageId: string | null; + publicReactions: boolean; + securityKeys: boolean; + twoFactorEnabled: boolean; + updatedAt: DateString | null; + uri: string | null; + url: string | null; + }; + + // @public (undocumented) + type UserGroup = TODO_2; + + // @public (undocumented) + type UserList = { + id: ID; + createdAt: DateString; + name: string; + userIds: User['id'][]; + }; + + // @public (undocumented) + type UserLite = { + id: ID; + username: string; + host: string | null; + name: string; + onlineStatus: 'online' | 'active' | 'offline' | 'unknown'; + avatarUrl: string; + avatarBlurhash: string; + alsoKnownAs: string[]; + movedToUri: any; + emojis: { + name: string; + url: string; + }[]; + instance?: { + name: Instance['name']; + softwareName: Instance['softwareName']; + softwareVersion: Instance['softwareVersion']; + iconUrl: Instance['iconUrl']; + faviconUrl: Instance['faviconUrl']; + themeColor: Instance['themeColor']; + }; + }; + + // @public (undocumented) + type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+updatedAt' | '-updatedAt'; + + // Warnings were encountered during analysis: + // + // src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts + // src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts + // src/api.types.ts:602:27 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts + // src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts + + // (No @packageDocumentation comment for this package) + + ``` diff --git a/packages/misskey-js/src/api.types.ts b/packages/misskey-js/src/api.types.ts index aed9f5bf84..3ca1f3d36d 100644 --- a/packages/misskey-js/src/api.types.ts +++ b/packages/misskey-js/src/api.types.ts @@ -499,6 +499,7 @@ export type Endpoints = { 'notes/mentions': { req: { following?: boolean; limit?: number; sinceId?: Note['id']; untilId?: Note['id']; }; res: Note[]; }; 'notes/polls/recommendation': { req: TODO; res: TODO; }; 'notes/polls/vote': { req: { noteId: Note['id']; choice: number; }; res: null; }; + 'notes/events/search': { req: TODO; res: Note[]; }; 'notes/reactions': { req: { noteId: Note['id']; type?: string | null; limit?: number; }; res: NoteReaction[]; }; 'notes/reactions/create': { req: { noteId: Note['id']; reaction: string; }; res: null; }; 'notes/reactions/delete': { req: { noteId: Note['id']; }; res: null; }; From 9ac5053a0eec98c2fafa71d2781bc4df3f5a22f8 Mon Sep 17 00:00:00 2001 From: ssmucny Date: Mon, 17 Apr 2023 20:29:40 -0400 Subject: [PATCH 10/35] Add simple display of event to note in UI --- .../api/endpoints/notes/events/search.ts | 2 +- packages/frontend/src/components/MkEvent.vue | 23 +++++++++++++++++++ packages/frontend/src/components/MkNote.vue | 2 ++ 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 packages/frontend/src/components/MkEvent.vue diff --git a/packages/backend/src/server/api/endpoints/notes/events/search.ts b/packages/backend/src/server/api/endpoints/notes/events/search.ts index 51c192714d..f286ed808d 100644 --- a/packages/backend/src/server/api/endpoints/notes/events/search.ts +++ b/packages/backend/src/server/api/endpoints/notes/events/search.ts @@ -100,7 +100,7 @@ export default class extends Endpoint { const matches = filters[f].filter(x => x !== null); if (matches.length < 1) throw new ApiError(meta.errors.invalidParam); query.andWhere(new Brackets((qb) => { - qb.where('event.metadata ->> :key IN (:...values)', { key: f, values: filters[f].filter(x => x !== null) }); + qb.where('event.metadata ->> :key IN (:...values)', { key: f, values: matches }); if (filters[f].filter(x => x === null).length > 0) { qb.orWhere('event.metadata ->> :key IS NULL', { key: f }); } diff --git a/packages/frontend/src/components/MkEvent.vue b/packages/frontend/src/components/MkEvent.vue new file mode 100644 index 0000000000..9fa351dcee --- /dev/null +++ b/packages/frontend/src/components/MkEvent.vue @@ -0,0 +1,23 @@ + + + + + diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 36ec778a14..8b87136966 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -45,6 +45,7 @@
+

@@ -144,6 +145,7 @@ import MkPoll from '@/components/MkPoll.vue'; import MkUsersTooltip from '@/components/MkUsersTooltip.vue'; import MkUrlPreview from '@/components/MkUrlPreview.vue'; import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; +import MkEvent from '@/components/MkEvent.vue'; import { pleaseLogin } from '@/scripts/please-login'; import { focusPrev, focusNext } from '@/scripts/focus'; import { checkWordMute } from '@/scripts/check-word-mute'; From 753cef9413a7017363513883131da34f620476e9 Mon Sep 17 00:00:00 2001 From: ssmucny Date: Thu, 20 Apr 2023 20:11:37 -0400 Subject: [PATCH 11/35] Add events tab to user details page --- .../api/endpoints/notes/events/search.ts | 16 +++--- packages/frontend/src/components/MkEvent.vue | 4 +- packages/frontend/src/pages/user/events.vue | 50 +++++++++++++++++++ packages/frontend/src/pages/user/index.vue | 8 ++- 4 files changed, 69 insertions(+), 9 deletions(-) create mode 100644 packages/frontend/src/pages/user/events.vue diff --git a/packages/backend/src/server/api/endpoints/notes/events/search.ts b/packages/backend/src/server/api/endpoints/notes/events/search.ts index f286ed808d..7693cf35cc 100644 --- a/packages/backend/src/server/api/endpoints/notes/events/search.ts +++ b/packages/backend/src/server/api/endpoints/notes/events/search.ts @@ -35,7 +35,7 @@ export const meta = { message: 'Invalid Parameter', code: 'INVALID_PARAM', id: 'e70903d3-0aa2-44d5-a955-4de5723c603d', - } + }, }, } as const; @@ -50,7 +50,7 @@ export const paramDef = { nullable: true, description: 'The local host is represented with `null`.', }, - users: { type: 'array', nullable: true, items: { type: 'object', format: 'misskey:id' } }, + users: { type: 'array', nullable: true, items: { type: 'string', format: 'misskey:id' } }, sinceDate: { type: 'integer', nullable: true }, untilDate: { type: 'integer', nullable: true }, filters: { @@ -109,15 +109,19 @@ export default class extends Endpoint { } if (ps.sinceDate && ps.untilDate && ps.sinceDate > ps.untilDate) throw new ApiError(meta.errors.invalidParam); - const sinceDate = ps.sinceDate ? new Date(ps.sinceDate) : new Date(); - query.andWhere('event.start > :sinceDate', { sinceDate: sinceDate }) - .andWhere('(event.end IS NULL OR event.end > :sinceDate)', { sinceDate: sinceDate }); + + if (ps.sinceDate || ps.sortBy !== 'createdAt') { + const sinceDate = ps.sinceDate ? new Date(ps.sinceDate) : new Date(); + query.andWhere('event.start > :sinceDate', { sinceDate: sinceDate }) + .andWhere('(event.end IS NULL OR event.end > :sinceDate)', { sinceDate: sinceDate }); + } + if (ps.untilDate) { query.andWhere('event.start < :untilDate', { untilDate: new Date(ps.untilDate) }); } if (ps.sortBy === 'createdAt') { - query.orderBy('note.createdAt', 'ASC'); + query.orderBy('note.createdAt', 'DESC'); } else { query.orderBy('event.start', 'ASC'); } diff --git a/packages/frontend/src/components/MkEvent.vue b/packages/frontend/src/components/MkEvent.vue index 9fa351dcee..b38986a23c 100644 --- a/packages/frontend/src/components/MkEvent.vue +++ b/packages/frontend/src/components/MkEvent.vue @@ -3,8 +3,8 @@

Start: {{ note.event!.start }}
End: {{ note.event!.end }}
    -
  • - {{ k }}: {{ note.event!.metadata[k] }} +
  • + {{ k }}: {{ note.event!.metadata[k] }}
diff --git a/packages/frontend/src/pages/user/events.vue b/packages/frontend/src/pages/user/events.vue new file mode 100644 index 0000000000..3e6ffe1a52 --- /dev/null +++ b/packages/frontend/src/pages/user/events.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue index 03a226cc09..1482ecd11b 100644 --- a/packages/frontend/src/pages/user/index.vue +++ b/packages/frontend/src/pages/user/index.vue @@ -5,7 +5,8 @@
- + + @@ -32,6 +33,7 @@ import { $i } from '@/account'; const XHome = defineAsyncComponent(() => import('./home.vue')); const XTimeline = defineAsyncComponent(() => import('./index.timeline.vue')); +const XEvent = defineAsyncComponent(() => import('./events.vue')); const XActivity = defineAsyncComponent(() => import('./activity.vue')); const XAchievements = defineAsyncComponent(() => import('./achievements.vue')); const XReactions = defineAsyncComponent(() => import('./reactions.vue')); @@ -74,6 +76,10 @@ const headerTabs = $computed(() => user ? [{ key: 'notes', title: i18n.ts.notes, icon: 'ti ti-pencil', +}, { + key: 'events', + title: 'Events', //i18n.ts.events, + icon: 'ti ti-calendar', }, { key: 'activity', title: i18n.ts.activity, From 07b3f19814b8a239280568ad70c62430b9892d11 Mon Sep 17 00:00:00 2001 From: ssmucny Date: Thu, 20 Apr 2023 22:00:06 -0400 Subject: [PATCH 12/35] Change displayed date/time format for events --- packages/frontend/src/components/MkEvent.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/frontend/src/components/MkEvent.vue b/packages/frontend/src/components/MkEvent.vue index b38986a23c..352e10c77b 100644 --- a/packages/frontend/src/components/MkEvent.vue +++ b/packages/frontend/src/components/MkEvent.vue @@ -1,7 +1,7 @@ @@ -49,6 +66,7 @@ import MkUserList from '@/components/MkUserList.vue'; import MkInput from '@/components/MkInput.vue'; import MkRadios from '@/components/MkRadios.vue'; import MkButton from '@/components/MkButton.vue'; +import MkSelect from '@/components/MkSelect.vue'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; import * as os from '@/os'; @@ -71,8 +89,10 @@ let key = $ref(''); let tab = $ref('note'); let searchQuery = $ref(''); let searchOrigin = $ref('combined'); +let eventSort = $ref('startDate'); let notePagination = $ref(); let userPagination = $ref(); +let eventPagination = $ref(); const notesSearchAvailable = (($i == null && instance.policies.canSearchNotes) || ($i != null && $i.policies.canSearchNotes)); @@ -85,7 +105,8 @@ onMounted(() => { async function search() { const query = searchQuery.toString().trim(); - if (query == null || query === '') return; + // only notes/users search use the query string. event does not use it + if ((query == null || query === '') && tab !== 'event') return; if (query.startsWith('https://')) { const promise = os.api('ap/show', { @@ -123,6 +144,19 @@ async function search() { origin: searchOrigin, }, }; + } else if (tab === 'event') { + eventPagination = { + endpoint: 'notes/events/search', + limit: 10, + params: { + sortBy: eventSort, + }, + }; + + // only refresh search on query/key change + key = JSON.stringify(eventPagination); + + return; } key = query; @@ -138,6 +172,10 @@ const headerTabs = $computed(() => [{ key: 'user', title: i18n.ts.users, icon: 'ti ti-users', +}, { + key: 'event', + title: 'Events', + icon: 'ti ti-calendar', }]); definePageMetadata(computed(() => ({ diff --git a/packages/frontend/src/pages/user/events.vue b/packages/frontend/src/pages/user/events.vue index 3e6ffe1a52..41b0bd50ca 100644 --- a/packages/frontend/src/pages/user/events.vue +++ b/packages/frontend/src/pages/user/events.vue @@ -3,11 +3,11 @@ - + @@ -23,7 +23,7 @@ const props = defineProps<{ user: misskey.entities.UserDetailed; }>(); -const include = ref(null); +const include = ref('upcoming'); const pagination = { endpoint: 'notes/events/search' as const, From 4ecdff8c2dffe793c46330300174e77199e63d3e Mon Sep 17 00:00:00 2001 From: ssmucny Date: Sun, 23 Apr 2023 13:53:44 -0400 Subject: [PATCH 17/35] Add query string to event search --- .../src/server/api/endpoints/notes/events/search.ts | 9 +++++++++ packages/frontend/src/pages/search.vue | 6 +++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/server/api/endpoints/notes/events/search.ts b/packages/backend/src/server/api/endpoints/notes/events/search.ts index 7693cf35cc..70a1df6475 100644 --- a/packages/backend/src/server/api/endpoints/notes/events/search.ts +++ b/packages/backend/src/server/api/endpoints/notes/events/search.ts @@ -9,6 +9,7 @@ import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../../error.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; export const meta = { tags: ['notes'], @@ -42,6 +43,7 @@ export const meta = { export const paramDef = { type: 'object', properties: { + query: { type: 'string', nullable: true }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, @@ -93,6 +95,13 @@ export default class extends Endpoint { .innerJoinAndSelect(Event, 'event', 'event.noteId = note.id') .innerJoinAndSelect('note.user', 'user'); + if (ps.query && ps.query.trim() !== '') { + query.andWhere(new Brackets((qb) => { + const q = (ps.query ?? '').trim(); + qb.where('event.title ILIKE :q', { q: `%${ sqlLikeEscape(q) }%` }) + .orWhere('note.text ILIKE :q', { q: `%${ sqlLikeEscape(q) }%` }); + })); + } if (ps.filters) { const filters: Record = ps.filters; diff --git a/packages/frontend/src/pages/search.vue b/packages/frontend/src/pages/search.vue index fe7641cc65..ff33773319 100644 --- a/packages/frontend/src/pages/search.vue +++ b/packages/frontend/src/pages/search.vue @@ -42,6 +42,9 @@
+ + + @@ -105,7 +108,7 @@ onMounted(() => { async function search() { const query = searchQuery.toString().trim(); - // only notes/users search use the query string. event does not use it + // only notes/users search require the query string if ((query == null || query === '') && tab !== 'event') return; if (query.startsWith('https://')) { @@ -149,6 +152,7 @@ async function search() { endpoint: 'notes/events/search', limit: 10, params: { + query: searchQuery, sortBy: eventSort, }, }; From a57c79061ca8446a3fcdb02f18b89f51e3b9daf9 Mon Sep 17 00:00:00 2001 From: ssmucny Date: Sun, 23 Apr 2023 14:51:54 -0400 Subject: [PATCH 18/35] Add date range to event search page --- packages/frontend/src/pages/search.vue | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/frontend/src/pages/search.vue b/packages/frontend/src/pages/search.vue index ff33773319..b8ff55f15f 100644 --- a/packages/frontend/src/pages/search.vue +++ b/packages/frontend/src/pages/search.vue @@ -50,6 +50,14 @@ +
+ + + + + + +
{{ i18n.ts.search }}
@@ -96,6 +104,8 @@ let eventSort = $ref('startDate'); let notePagination = $ref(); let userPagination = $ref(); let eventPagination = $ref(); +let startDate = $ref(null); +let endDate = $ref(null); const notesSearchAvailable = (($i == null && instance.policies.canSearchNotes) || ($i != null && $i.policies.canSearchNotes)); @@ -152,8 +162,10 @@ async function search() { endpoint: 'notes/events/search', limit: 10, params: { - query: searchQuery, + query: !searchQuery ? undefined : searchQuery, sortBy: eventSort, + sinceDate: startDate ? (new Date(startDate)).getTime() : undefined, + untilDate: endDate ? (new Date(endDate)).getTime() + 1000 * 3600 * 24 : undefined, }, }; From b3fb7c9271f88d8e7ddbadaa575fa06004f16fff Mon Sep 17 00:00:00 2001 From: ssmucny Date: Sun, 23 Apr 2023 15:55:22 -0400 Subject: [PATCH 19/35] Update API types --- .../api/endpoints/notes/events/search.ts | 5 ----- packages/misskey-js/src/api.types.ts | 18 +++++++++++++++++- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/server/api/endpoints/notes/events/search.ts b/packages/backend/src/server/api/endpoints/notes/events/search.ts index 70a1df6475..55689b4e18 100644 --- a/packages/backend/src/server/api/endpoints/notes/events/search.ts +++ b/packages/backend/src/server/api/endpoints/notes/events/search.ts @@ -47,11 +47,6 @@ export const paramDef = { sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - host: { - type: 'string', - nullable: true, - description: 'The local host is represented with `null`.', - }, users: { type: 'array', nullable: true, items: { type: 'string', format: 'misskey:id' } }, sinceDate: { type: 'integer', nullable: true }, untilDate: { type: 'integer', nullable: true }, diff --git a/packages/misskey-js/src/api.types.ts b/packages/misskey-js/src/api.types.ts index 3ca1f3d36d..ad40528cf7 100644 --- a/packages/misskey-js/src/api.types.ts +++ b/packages/misskey-js/src/api.types.ts @@ -488,6 +488,12 @@ export type Endpoints = { expiresAt?: null | number; expiredAfter?: null | number; }; + event?: null | { + title: string; + start: number; + end?: null | number; + metadata: Record; + } }; res: { createdNote: Note }; }; 'notes/delete': { req: { noteId: Note['id']; }; res: null; }; 'notes/favorites/create': { req: { noteId: Note['id']; }; res: null; }; @@ -499,7 +505,17 @@ export type Endpoints = { 'notes/mentions': { req: { following?: boolean; limit?: number; sinceId?: Note['id']; untilId?: Note['id']; }; res: Note[]; }; 'notes/polls/recommendation': { req: TODO; res: TODO; }; 'notes/polls/vote': { req: { noteId: Note['id']; choice: number; }; res: null; }; - 'notes/events/search': { req: TODO; res: Note[]; }; + 'notes/events/search': { req: { + query?: string; + sinceId?: Note['id']; + untilId?: Note['id']; + limit?: number; + users?: User['id'][]; + sinceDate?: number; + untilDate?: number; + sortBy?: 'startDate' | 'craetedAt'; + filters?: Record; + }; res: Note[]; }; 'notes/reactions': { req: { noteId: Note['id']; type?: string | null; limit?: number; }; res: NoteReaction[]; }; 'notes/reactions/create': { req: { noteId: Note['id']; reaction: string; }; res: null; }; 'notes/reactions/delete': { req: { noteId: Note['id']; }; res: null; }; From 661c83a23f81c0372549660c20108e9033acff9b Mon Sep 17 00:00:00 2001 From: ssmucny Date: Sun, 23 Apr 2023 23:29:47 -0400 Subject: [PATCH 20/35] bug fixes and localization: - add localization strings - fixed duplicates in search (use offset when sorting by startDate) --- locales/ja-JP.yml | 12 ++++++++++++ .../server/api/endpoints/notes/events/search.ts | 2 ++ packages/frontend/src/components/MkEvent.vue | 1 - packages/frontend/src/components/MkEventEditor.vue | 14 +++++++------- packages/frontend/src/pages/search.vue | 13 +++++++------ packages/frontend/src/pages/user/events.vue | 5 +++-- packages/frontend/src/pages/user/index.vue | 2 +- packages/misskey-js/src/api.types.ts | 1 + 8 files changed, 33 insertions(+), 17 deletions(-) diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 4c5bb60e0c..77691236bc 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -993,6 +993,18 @@ noteIdOrUrl: "ノートIDまたはURL" accountMigration: "アカウントの引っ越し" accountMoved: "このユーザーは新しいアカウントに引っ越しました:" forceShowAds: "常に広告を表示する" +event: "イベント" +events: "イベント" +reverseChronological: "倒叙" + +_event: + title: "題" + startDate: "開始日" + endDate: "終了日" + startTime: "開始時刻" + endTime: "終了時刻" + detailName: "属性" + detailValue: "値" _accountMigration: moveTo: "このアカウントを新しいアカウントに引っ越す" diff --git a/packages/backend/src/server/api/endpoints/notes/events/search.ts b/packages/backend/src/server/api/endpoints/notes/events/search.ts index 55689b4e18..54d5c3af65 100644 --- a/packages/backend/src/server/api/endpoints/notes/events/search.ts +++ b/packages/backend/src/server/api/endpoints/notes/events/search.ts @@ -47,6 +47,7 @@ export const paramDef = { sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + offset: { type: 'integer', default: 0 }, users: { type: 'array', nullable: true, items: { type: 'string', format: 'misskey:id' } }, sinceDate: { type: 'integer', nullable: true }, untilDate: { type: 'integer', nullable: true }, @@ -134,6 +135,7 @@ export default class extends Endpoint { if (me) this.queryService.generateMutedUserQuery(query, me); if (me) this.queryService.generateBlockedUserQuery(query, me); + if (ps.offset) query.skip(ps.offset); const notes = await query.take(ps.limit).getMany(); return await this.noteEntityService.packMany(notes, me); diff --git a/packages/frontend/src/components/MkEvent.vue b/packages/frontend/src/components/MkEvent.vue index 352e10c77b..1933515f35 100644 --- a/packages/frontend/src/components/MkEvent.vue +++ b/packages/frontend/src/components/MkEvent.vue @@ -11,7 +11,6 @@ From 47f43e897b7785a1940228a2809d00e534199235 Mon Sep 17 00:00:00 2001 From: ssmucny Date: Thu, 18 May 2023 22:22:29 -0400 Subject: [PATCH 29/35] Change events to use schema.org/Event for metadata --- packages/backend/src/models/entities/Event.ts | 9 ++++++--- .../src/server/api/endpoints/notes/events/search.ts | 11 ++++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/models/entities/Event.ts b/packages/backend/src/models/entities/Event.ts index 9d986b7c85..57d2b50543 100644 --- a/packages/backend/src/models/entities/Event.ts +++ b/packages/backend/src/models/entities/Event.ts @@ -35,10 +35,13 @@ export class Event { public title: string; @Column('jsonb', { - default: {}, - comment: 'metadata mapping for event with more user configurable optional information', + default: { + '@context': 'https://schema.org/', + '@type': 'Event', + }, + comment: 'metadata object describing the event. Follows https://schema.org/Event', }) - public metadata: Record; + public metadata: unknown; //#region Denormalized fields @Column('enum', { diff --git a/packages/backend/src/server/api/endpoints/notes/events/search.ts b/packages/backend/src/server/api/endpoints/notes/events/search.ts index f0b04ade25..dfc614039c 100644 --- a/packages/backend/src/server/api/endpoints/notes/events/search.ts +++ b/packages/backend/src/server/api/endpoints/notes/events/search.ts @@ -8,8 +8,8 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; -import { ApiError } from '../../../error.js'; import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; +import { ApiError } from '../../../error.js'; export const meta = { tags: ['notes'], @@ -58,7 +58,7 @@ export const paramDef = { items: { type: 'object', properties: { - key: { type: 'string', description: 'the metadata property to filter on.' }, + key: { type: 'array', items: { type: 'string', nullable: false }, description: 'the metadata string property to filter on. Can filter on nested properties using an array. such as `["location", "postalCode"]`.' }, values: { type: 'array', items: { type: 'string', nullable: true }, description: 'The values to match the metadata against (case insensitive regex). Each item in this array is applied as an OR. Include null to indicate match on missing metadata' }, }, }, @@ -67,6 +67,10 @@ export const paramDef = { }, } as const; +function notAlphaNumeric(s: string) { + return null !== s.match(/[^\w]/); +} + // eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint { @@ -112,13 +116,14 @@ export default class extends Endpoint { filters.forEach(f => { if (!f.key || !f.values) throw new ApiError(meta.errors.invalidParam); const filterKey = f.key; + if (filterKey.some(notAlphaNumeric)) throw new ApiError(meta.errors.invalidParam); // schema properties don't have special characters const filterValues = f.values as (string | null)[]; const matches = filterValues.filter(x => x !== null) as string[]; const hasNull = filterValues.length !== matches.length; if (matches.length < 1) throw new ApiError(meta.errors.invalidParam); query.andWhere(new Brackets((qb) => { // regex match metadata values case insensitive - qb.where('event.metadata ->> :key ~* :values', { key: filterKey, values: `(${ matches.map(m => m.trim()).filter(m => m.length).join('|') })` }); + qb.where('event.metadata #>> :key ~* :values', { key: `{${filterKey.join(',')}}`, values: `(${ matches.map(m => m.trim()).filter(m => m.length).join('|') })` }); if (hasNull) { qb.orWhere('NOT (event.metadata ? :key)', { key: filterKey }); } From b7c7e4d32da3e4063958b18bf340dc7b45e2072f Mon Sep 17 00:00:00 2001 From: ssmucny Date: Mon, 22 May 2023 22:09:12 -0400 Subject: [PATCH 30/35] Add Event schema to metadata - add types from https://schema.org/Event - updated MkEvent to display new fields - minor refactoring --- .../core/activitypub/models/ApEventService.ts | 8 +-- packages/backend/src/models/entities/Event.ts | 43 ++++++++++++- .../api/endpoints/notes/events/search.ts | 2 +- packages/frontend/src/components/MkEvent.vue | 63 ++++++++++++++++++- packages/misskey-js/src/api.types.ts | 2 +- 5 files changed, 106 insertions(+), 12 deletions(-) diff --git a/packages/backend/src/core/activitypub/models/ApEventService.ts b/packages/backend/src/core/activitypub/models/ApEventService.ts index 81d40a88f6..b525f09ae0 100644 --- a/packages/backend/src/core/activitypub/models/ApEventService.ts +++ b/packages/backend/src/core/activitypub/models/ApEventService.ts @@ -3,13 +3,13 @@ import { DI } from '@/di-symbols.js'; import type { EventsRepository, NotesRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; +import { bindThis } from '@/decorators.js'; +import { IEvent } from '@/models/entities/Event.js'; import { isEvent } from '../type.js'; import { ApLoggerService } from '../ApLoggerService.js'; import { ApResolverService } from '../ApResolverService.js'; import type { Resolver } from '../ApResolverService.js'; import type { IObject } from '../type.js'; -import { bindThis } from '@/decorators.js'; -import { IEvent } from '@/models/entities/Event.js'; @Injectable() export class ApEventService { @@ -32,8 +32,8 @@ export class ApEventService { } @bindThis - public async extractEventFromNote(source: string | IObject, resolver?: Resolver): Promise { - if (resolver == null) resolver = this.apResolverService.createResolver(); + public async extractEventFromNote(source: string | IObject, resolverParam?: Resolver): Promise { + const resolver = resolverParam ?? this.apResolverService.createResolver(); const note = await resolver.resolve(source); diff --git a/packages/backend/src/models/entities/Event.ts b/packages/backend/src/models/entities/Event.ts index 57d2b50543..bad8b5d8ac 100644 --- a/packages/backend/src/models/entities/Event.ts +++ b/packages/backend/src/models/entities/Event.ts @@ -35,13 +35,13 @@ export class Event { public title: string; @Column('jsonb', { - default: { + default: { '@context': 'https://schema.org/', '@type': 'Event', }, comment: 'metadata object describing the event. Follows https://schema.org/Event', }) - public metadata: unknown; + public metadata: EventSchema; //#region Denormalized fields @Column('enum', { @@ -74,9 +74,46 @@ export class Event { } } +export type EventSchema = { + '@context': 'https://schema.org'; + '@type': 'Event'; + name?: string; + url?: string; + description?: string; + audience?: { + '@type': 'Audience'; + name: string; + }; + doorTime?: string; + startDate?: string; + endDate?: string; + eventStatus?: 'https://schema.org/EventCancelled' | 'https://schema.org/EventMovedOnline' | 'https://schema.org/EventPostponed' | 'https://schema.org/EventRescheduled' | 'https://schema.org/EventScheduled'; + inLanguage?: string; + isAccessibleForFree?: boolean; + keywords?: string; + location?: string; + offers?: { + '@type': 'Offer'; + price?: string; + priceCurrency?: string; + availabilityStarts?: string; + availabilityEnds?: string; + url?: string; + }; + organizer?: { + name: string; + sameAs?: string; // ie. URL to website/social + }; + performer?: { + name: string; + sameAs?: string; // ie. URL to website/social + }[]; + typicalAgeRange?: string; +} + export type IEvent = { start: Date; end: Date | null title: string; - metadata: Record; + metadata: EventSchema; } diff --git a/packages/backend/src/server/api/endpoints/notes/events/search.ts b/packages/backend/src/server/api/endpoints/notes/events/search.ts index dfc614039c..737867b1ef 100644 --- a/packages/backend/src/server/api/endpoints/notes/events/search.ts +++ b/packages/backend/src/server/api/endpoints/notes/events/search.ts @@ -67,7 +67,7 @@ export const paramDef = { }, } as const; -function notAlphaNumeric(s: string) { +function notAlphaNumeric(s: string): boolean { return null !== s.match(/[^\w]/); } diff --git a/packages/frontend/src/components/MkEvent.vue b/packages/frontend/src/components/MkEvent.vue index 3b445ce1b5..a3dd01e796 100644 --- a/packages/frontend/src/components/MkEvent.vue +++ b/packages/frontend/src/components/MkEvent.vue @@ -15,9 +15,66 @@ - -