diff --git a/CHANGELOG.md b/CHANGELOG.md index 4da95b85ba..832f927b6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,7 @@ Meilisearchの設定に`index`が必要になりました。値はMisskeyサー - Node.js 18.16.0以上が必要になりました ### General +- Add support for user created events. Includes basic federation of ActivityPub Event objects. [PR 10628](https://github.com/misskey-dev/misskey/pull/10628) @ssmucny - アカウントの引っ越し(フォロワー引き継ぎ)に対応 - Meilisearchを全文検索に使用できるようになりました * 「フォロワーのみ」の投稿は検索結果に表示されません。 diff --git a/CHANGELOG_CHERRYPICK.md b/CHANGELOG_CHERRYPICK.md index 5c096d57af..0156b21b8d 100644 --- a/CHANGELOG_CHERRYPICK.md +++ b/CHANGELOG_CHERRYPICK.md @@ -44,6 +44,7 @@ - 노트 작성 폼에서 본문 미리보기 상태 기억 ([shrimpia/misskey](https://github.com/shrimpia/misskey)) - 리모트에 존재하는 커스텀 이모지도 자신의 서버 내에 같은 이름의 이모지가 있으면 리액션 할 수 있도록 ([shrimpia/misskey@e91295f](https://github.com/shrimpia/misskey/commit/e91295ff9c6f8ac90f61c8de7a891a6836e48e95), [shrimpia/misskey@010378f](https://github.com/shrimpia/misskey/commit/010378fae659ad3015bfade4346209e01bb2a902), [shrimpia/misskey@acf2a30](https://github.com/shrimpia/misskey/commit/acf2a30e8a8c57525dfbab499dbb0b6c7d8e43c2)) - 「이미 본 리노트를 간략화하기」 옵션의 기본값을 꺼짐으로 설정 +- 이벤트 기능 (misskey-dev/misskey#10628) ### Client - (Friendly) 일부 페이지를 제외하고 플로팅 버튼을 표시하지 않음 diff --git a/locales/en-US.yml b/locales/en-US.yml index 0cc8f73a67..1d6177334d 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1065,6 +1065,9 @@ accountMoved: "This user has moved to a new account:" accountMovedShort: "This account has been migrated." operationForbidden: "Operation forbidden" forceShowAds: "Always show ads" +event: "Event" +events: "Events" +reverseChronological: "flashback" addMemo: "Add memo" editMemo: "Edit memo" reactionsList: "Reactions" @@ -1155,6 +1158,34 @@ _initialAccountSetting: laterAreYouSure: "Really do profile setup later?" _serverRules: description: "A set of rules to be displayed before registration. Setting a summary of the Terms of Service is recommended." +_event: + title: "Title" + startDateTime: "Start date time" + endDateTime: "End date time" + startDate: "Start date" + endDate: "End date" + startTime: "Start time" + endTime: "End time" + detailName: "Details" + detailValue: "value" + location: "Location" + url: "URL" + doorTime: "Door Time" + organizer: "Organizer" + organizerLink: "Organizer Link" + audience: "Audience" + language: "Language" + ageRange: "Age Range" + ticketsUrl: "Tickets" + isFree: "Free" + price: "Price" + availability: "Availability" + from: "From" + until: "Until" + availabilityStart: "Availability Start" + availabilityEnd: "Availability End" + keywords: "Keywords" + performers: "Performers" _accountMigration: moveFrom: "Migrate another account to this one" moveFromSub: "Create alias to another account" diff --git a/locales/index.d.ts b/locales/index.d.ts index 93d3d07df6..f232d88b7a 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1068,6 +1068,9 @@ export interface Locale { "accountMovedShort": string; "operationForbidden": string; "forceShowAds": string; + "event": string; + "events": string; + "reverseChronological": string; "addMemo": string; "editMemo": string; "reactionsList": string; @@ -1164,6 +1167,35 @@ export interface Locale { "_serverRules": { "description": string; }; + "_event": { + "title": string; + "startDateTime": string; + "endDateTime": string; + "startDate": string; + "endDate": string; + "startTime": string; + "endTime": string; + "detailName": string; + "detailValue": string; + "location": string; + "url": string; + "doorTime": string; + "organizer": string; + "organizerLink": string; + "audience": string; + "language": string; + "ageRange": string; + "ticketsUrl": string; + "isFree": string; + "price": string; + "availability": string; + "from": string; + "until": string; + "availabilityStart": string; + "availabilityEnd": string; + "keywords": string; + "performers": string; + }; "_accountMigration": { "moveFrom": string; "moveFromSub": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index e6b53eb91d..d93585f86a 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1065,6 +1065,9 @@ accountMoved: "このユーザーは新しいアカウントに移行しまし accountMovedShort: "このアカウントは移行されています" operationForbidden: "この操作はできません" forceShowAds: "常に広告を表示する" +event: "イベント" +events: "イベント" +reverseChronological: "倒叙" addMemo: "メモを追加" editMemo: "メモを編集" reactionsList: "リアクション一覧" @@ -1162,6 +1165,35 @@ _initialAccountSetting: _serverRules: description: "新規登録前に表示する、サーバーの簡潔なルールを設定します。内容は利用規約の要約とすることを推奨します。" +_event: + title: "題名" + startDateTime: "開始日時" + endDateTime: "終了日時" + startDate: "開始日" + endDate: "終了日" + startTime: "開始時刻" + endTime: "終了時刻" + detailName: "属性" + detailValue: "値" + location: "所在地" + url: "URL" + doorTime: "ドアタイム" + organizer: "主催者" + organizerLink: "主催者リンク" + audience: "オーディエンス" + language: "言語" + ageRange: "年齢層" + ticketsUrl: "チケット" + isFree: "無料" + price: "価格" + availability: "可用性" + from: "から" + until: "まで" + availabilityStart: "アベイラビリティ開始" + availabilityEnd: "アベイラビリティ終了" + keywords: "キーワード" + performers: "出演者" + _accountMigration: moveFrom: "別のアカウントからこのアカウントに移行" moveFromSub: "別のアカウントへエイリアスを作成" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 04c6d56a04..97c0acebf6 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -1066,6 +1066,9 @@ accountMoved: "이 사용자는 다음 계정으로 이사했어요:" accountMovedShort: "이사한 계정이에요" operationForbidden: "사용할 수 없어요" forceShowAds: "광고를 항상 표시" +event: "이벤트" +events: "이벤트" +reverseChronological: "미래순" addMemo: "메모 추가" editMemo: "메모 편집" reactionsList: "리액션 목록" @@ -1155,6 +1158,34 @@ _initialAccountSetting: laterAreYouSure: "초기 설정을 나중에 진행할까요?" _serverRules: description: "회원 가입 이전에 간단하게 표시할 서버 규칙이에요. 이용 약관의 요약으로 구성하는 것을 추천해요." +_event: + title: "제목" + startDateTime: "시작 일시" + endDateTime: "종료 일시" + startDate: "시작일" + endDate: "종료일" + startTime: "시작 시간" + endTime: "종료 시간" + detailName: "속성" + detailValue: "값" + location: "위치" + url: "URL" + doorTime: "출입 가능 시간" + organizer: "주최자" + organizerLink: "주최자 링크" + audience: "대상" + language: "언어" + ageRange: "연령층" + ticketsUrl: "티켓" + isFree: "무료" + price: "가격" + availability: "사용 가능 여부" + from: "부터" + until: "까지" + availabilityStart: "이용 가능 시간" + availabilityEnd: "이용 종료 시간" + keywords: "키워드" + performers: "출연자" _accountMigration: moveFrom: "다른 계정에서 이 계정으로 이사" moveFromSub: "다른 계정에 대한 별칭을 생성" diff --git a/packages/backend/migration/1681429921400-Event.js b/packages/backend/migration/1681429921400-Event.js new file mode 100644 index 0000000000..9dc2eb39b6 --- /dev/null +++ b/packages/backend/migration/1681429921400-Event.js @@ -0,0 +1,91 @@ +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(`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 "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/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`); + } +} 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/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 5f25850ee5..410397c305 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -118,6 +118,7 @@ import { ApMentionService } from './activitypub/models/ApMentionService.js'; import { ApNoteService } from './activitypub/models/ApNoteService.js'; import { ApPersonService } from './activitypub/models/ApPersonService.js'; import { ApQuestionService } from './activitypub/models/ApQuestionService.js'; +import { ApEventService } from './activitypub/models/ApEventService.js'; import { QueueModule } from './QueueModule.js'; import { QueueService } from './QueueService.js'; import { LoggerService } from './LoggerService.js'; @@ -247,6 +248,7 @@ const $ApMentionService: Provider = { provide: 'ApMentionService', useExisting: const $ApNoteService: Provider = { provide: 'ApNoteService', useExisting: ApNoteService }; const $ApPersonService: Provider = { provide: 'ApPersonService', useExisting: ApPersonService }; const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting: ApQuestionService }; +const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEventService }; //#endregion @Module({ @@ -374,6 +376,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApNoteService, ApPersonService, ApQuestionService, + ApEventService, QueueService, //#region 文字列ベースでのinjection用(循環参照対応のため) @@ -497,6 +500,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ApNoteService, $ApPersonService, $ApQuestionService, + $ApEventService, //#endregion ], exports: [ @@ -620,6 +624,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApNoteService, ApPersonService, ApQuestionService, + ApEventService, QueueService, //#region 文字列ベースでのinjection用(循環参照対応のため) @@ -742,6 +747,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ApNoteService, $ApPersonService, $ApQuestionService, + $ApEventService, //#endregion ], }) diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index a6b72793dd..c590913960 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -9,6 +9,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'; @@ -128,6 +129,7 @@ type Option = { renote?: Note | null; files?: DriveFile[] | null; poll?: IPoll | null; + event?: IEvent | null; localOnly?: boolean | null; reactionAcceptance?: Note['reactionAcceptance']; disableRightClick?: boolean | null; @@ -364,6 +366,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, @@ -409,23 +412,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/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index be68a92908..d2ada2bd18 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -20,7 +20,7 @@ import { MfmService } from '@/core/MfmService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import type { UserKeypair } from '@/models/entities/UserKeypair.js'; -import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFilesRepository, EmojisRepository, PollsRepository } from '@/models/index.js'; +import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFilesRepository, EmojisRepository, PollsRepository, EventsRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { isNotNull } from '@/misc/is-not-null.js'; @@ -53,6 +53,9 @@ export class ApRendererService { @Inject(DI.pollsRepository) private pollsRepository: PollsRepository, + @Inject(DI.eventsRepository) + private eventsRepository: EventsRepository, + private customEmojiService: CustomEmojiService, private userEntityService: UserEntityService, private driveFileEntityService: DriveFileEntityService, @@ -429,6 +432,17 @@ export class ApRendererService { _misskey_talk: true, } as const : {}; + let asEvent = {}; + if (note.hasEvent) { + const event = await this.eventsRepository.findOneBy({ noteId: note.id }); + asEvent = event ? { + type: 'Event', + name: event.title, + startTime: event.start, + endTime: event.end, + } as const : {}; + } + return { id: `${this.config.url}/notes/${note.id}`, type: 'Note', @@ -449,6 +463,7 @@ export class ApRendererService { attachment: files.map(x => this.renderDocument(x)), sensitive: note.cw != null || files.some(file => file.isSensitive), tag, + ...asEvent, ...asPoll, ...asTalk, }; diff --git a/packages/backend/src/core/activitypub/models/ApEventService.ts b/packages/backend/src/core/activitypub/models/ApEventService.ts new file mode 100644 index 0000000000..50cbbe14ca --- /dev/null +++ b/packages/backend/src/core/activitypub/models/ApEventService.ts @@ -0,0 +1,68 @@ +import { Inject, Injectable } from '@nestjs/common'; +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'; + +@Injectable() +export class ApEventService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.eventsRepository) + private eventsRepository: EventsRepository, + + private apResolverService: ApResolverService, + private apLoggerService: ApLoggerService, + ) { + this.logger = this.apLoggerService.logger; + } + + @bindThis + public async extractEventFromNote(source: string | IObject, resolverParam?: Resolver): Promise { + const resolver = resolverParam ?? this.apResolverService.createResolver(); + + const note = await resolver.resolve(source); + + if (!isEvent(note)) { + throw new Error('invalid type'); + } + + if (note.name && note.startTime) { + const title = note.name; + const start = note.startTime; + const end = note.endTime ?? null; + + return { + title, + start, + end, + metadata: { + '@context': 'https://schema.org', + '@type': 'Event', + name: note.name, + url: note.href, + startDate: note.startTime.toISOString(), + endDate: note.endTime?.toISOString(), + description: note.summary, + identifier: note.id, + }, + }; + } else { + throw new Error('Invalid event properties'); + } + } +} diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 09ab2d209d..05cbaab4df 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -31,6 +31,7 @@ import { ApPersonService } from './ApPersonService.js'; import { extractApHashtags } from './tag.js'; import { ApMentionService } from './ApMentionService.js'; import { ApQuestionService } from './ApQuestionService.js'; +import { ApEventService } from './ApEventService.js'; import { ApImageService } from './ApImageService.js'; import type { Resolver } from '../ApResolverService.js'; import type { IObject, IPost } from '../type.js'; @@ -65,6 +66,7 @@ export class ApNoteService { private apMentionService: ApMentionService, private apImageService: ApImageService, private apQuestionService: ApQuestionService, + private apEventService: ApEventService, private metaService: MetaService, private messagingService: MessagingService, private appLockService: AppLockService, @@ -295,6 +297,7 @@ export class ApNoteService { const apEmojis = emojis.map(emoji => emoji.name); const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined); + const event = await this.apEventService.extractEventFromNote(note, resolver).catch(() => undefined); if (isMessaging) { for (const recipient of visibleUsers) { @@ -319,6 +322,7 @@ export class ApNoteService { apHashtags, apEmojis, poll, + event, uri: note.id, url: url, }, silent); diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 7661f3ce70..796dace36c 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -134,6 +134,9 @@ export interface IQuestion extends IObject { export const isQuestion = (object: IObject): object is IQuestion => getApType(object) === 'Note' || getApType(object) === 'Question'; +export const isEvent = (object: IObject): object is IObject => + getApType(object) === 'Note' || getApType(object) === 'Event'; + interface IQuestionChoice { name?: string; replies?: ICollection; diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index dbffebe8b7..82d31e7037 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; @@ -354,6 +368,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 3d69462601..72c8d7be08 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -37,6 +37,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 a7d55eb01d..1f63829d94 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, UserGroup, UserGroupJoining, UserGroupInvitation, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, MessagingMessage, 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, UserMemo, UserListFavorite } from './index.js'; +import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserGroup, UserGroupJoining, UserGroupInvitation, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, Event, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, MessagingMessage, 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, UserMemo, UserListFavorite } from './index.js'; import type { DataSource } from 'typeorm'; import type { Provider } from '@nestjs/common'; @@ -184,6 +184,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), @@ -458,6 +464,7 @@ const $userMemosRepository: Provider = { $followRequestsRepository, $instancesRepository, $emojisRepository, + $eventsRepository, $driveFilesRepository, $driveFoldersRepository, $metasRepository, @@ -530,6 +537,7 @@ const $userMemosRepository: Provider = { $followRequestsRepository, $instancesRepository, $emojisRepository, + $eventsRepository, $driveFilesRepository, $driveFoldersRepository, $metasRepository, diff --git a/packages/backend/src/models/entities/Event.ts b/packages/backend/src/models/entities/Event.ts new file mode 100644 index 0000000000..db48bda7b4 --- /dev/null +++ b/packages/backend/src/models/entities/Event.ts @@ -0,0 +1,120 @@ +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 { + @PrimaryColumn(id()) + public noteId: Note['id']; + + @OneToOne(type => Note, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public note: Note | null; + + @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: { + '@context': 'https://schema.org/', + '@type': 'Event', + }, + comment: 'metadata object describing the event. Follows https://schema.org/Event', + }) + public metadata: EventSchema; + + //#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 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; + identifier?: string; +} + +export type IEvent = { + start: Date; + end: Date | null + title: string; + metadata: EventSchema; +} diff --git a/packages/backend/src/models/entities/Note.ts b/packages/backend/src/models/entities/Note.ts index 3dd2fc7de0..2f70b77757 100644 --- a/packages/backend/src/models/entities/Note.ts +++ b/packages/backend/src/models/entities/Note.ts @@ -53,6 +53,11 @@ export class Note { }) public threadId: string | null; + @Column('boolean', { + default: false, + }) + public hasEvent: boolean; + // TODO: varcharにしたい @Column('text', { nullable: true, diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index 15346c1b0f..6faf1e8f44 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'; @@ -89,6 +90,7 @@ export { DriveFile, DriveFolder, Emoji, + Event, Following, FollowRequest, GalleryLike, @@ -161,6 +163,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; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 6897eb07b7..9b056f34c2 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'; @@ -165,6 +166,7 @@ export const entities = [ Poll, PollVote, Emoji, + Event, Hashtag, SwSubscription, AbuseUserReport, diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 79b0589fdc..4ff4406e99 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -264,6 +264,7 @@ import * as ep___notes_mediaTimeline from './endpoints/notes/media-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'; @@ -625,6 +626,7 @@ const $notes_mediaTimeline: Provider = { provide: 'ep:notes/media-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 }; @@ -990,6 +992,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, @@ -1348,6 +1351,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 a03759a10a..f36a37a6cd 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -264,6 +264,7 @@ import * as ep___notes_mediaTimeline from './endpoints/notes/media-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'; @@ -623,6 +624,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/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 9158b74610..d9c9227b75 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -147,6 +147,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: [ @@ -282,6 +292,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, 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..f3573edb2f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/events/search.ts @@ -0,0 +1,171 @@ +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 { sqlLikeEscape } from '@/misc/sql-like-escape.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: { + 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 }, + origin: { type: 'string', enum: ['local', 'remote', 'combined'], default: 'combined' }, + 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 }, + filters: { + type: 'array', + nullable: true, + description: 'list of string -> [string] that filters events based on metadata. Each item in filters is applied as an AND', + items: { + type: 'object', + properties: { + 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' }, + }, + }, + }, + sortBy: { type: 'string', nullable: true, default: 'startDate', enum: ['startDate', 'createdAt'] }, + }, +} as const; + +function notAlphaNumeric(s: string): boolean { + return null !== s.match(/[^\w]/); +} + +// 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 queryRunner = this.notesRepository.queryRunner; + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note', queryRunner), ps.sinceId, ps.untilId); + + if (ps.origin === 'local') { + query.andWhere('note.userHost IS NULL'); + } else if (ps.origin === 'remote') { + query.andWhere('note.userHost IS NOT NULL'); + } + + 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.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 = ps.filters; + + 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.join(',')}}`, values: `(${ matches.map(m => m.trim()).filter(m => m.length).join('|') })` }); + if (hasNull) { + qb.orWhere('NOT (event.metadata ? :key)', { key: filterKey }); + } + })); + }); + } + + if (ps.sinceDate && ps.untilDate && ps.sinceDate > ps.untilDate) throw new ApiError(meta.errors.invalidParam); + + 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', 'DESC'); + } 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); + + if (ps.offset) query.skip(ps.offset); + + query.maxExecutionTime(250); // because we include regex expressions in where clause, defend against long running regex with timeout + const notes = await query.take(ps.limit).getMany(); + + return await this.noteEntityService.packMany(notes, me); + }); + } +} diff --git a/packages/cherrypick-js/etc/cherrypick-js.api.md b/packages/cherrypick-js/etc/cherrypick-js.api.md index 4a237d0dd6..70916d4c3c 100644 --- a/packages/cherrypick-js/etc/cherrypick-js.api.md +++ b/packages/cherrypick-js/etc/cherrypick-js.api.md @@ -1677,6 +1677,12 @@ export type Endpoints = { expiresAt?: null | number; expiredAfter?: null | number; }; + event?: null | { + title: string; + start: number; + end?: null | number; + metadata: Record; + }; }; res: { createdNote: Note; @@ -1754,6 +1760,24 @@ export type Endpoints = { }; res: null; }; + 'notes/events/search': { + req: { + query?: string; + sinceId?: Note['id']; + untilId?: Note['id']; + limit?: number; + offset?: number; + users?: User['id'][]; + sinceDate?: number; + untilDate?: number; + sortBy?: 'startDate' | 'craetedAt'; + filters?: { + key: string; + values: (string | null)[]; + }[]; + }; + res: Note[]; + }; 'notes/reactions': { req: { noteId: Note['id']; @@ -2406,6 +2430,12 @@ 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'; @@ -2719,7 +2749,7 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u // // 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/api.types.ts:614: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 // (No @packageDocumentation comment for this package) diff --git a/packages/cherrypick-js/src/api.types.ts b/packages/cherrypick-js/src/api.types.ts index b8c59e7b15..7d12716bd7 100644 --- a/packages/cherrypick-js/src/api.types.ts +++ b/packages/cherrypick-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,6 +505,18 @@ 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: { + query?: string; + sinceId?: Note['id']; + untilId?: Note['id']; + limit?: number; + offset?: number; + users?: User['id'][]; + sinceDate?: number; + untilDate?: number; + sortBy?: 'startDate' | 'craetedAt'; + filters?: { key: string[], values: (string | null)[] }[]; + }; 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; }; diff --git a/packages/cherrypick-js/src/entities.ts b/packages/cherrypick-js/src/entities.ts index 383b17f0b9..bc97a14a01 100644 --- a/packages/cherrypick-js/src/entities.ts +++ b/packages/cherrypick-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'; diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx index f442422109..5ead25ba1b 100644 --- a/packages/frontend/.storybook/generate.tsx +++ b/packages/frontend/.storybook/generate.tsx @@ -397,6 +397,7 @@ function toStories(component: string): string { Promise.all([ glob('src/components/global/*.vue'), glob('src/components/Mk{A,B}*.vue'), + glob('src/components/MkEvent.vue'), glob('src/components/MkDigitalClock.vue'), glob('src/components/MkGalleryPostPreview.vue'), glob('src/components/MkSignupServerRules.vue'), diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue index 6942a0e6c3..573754dc76 100644 --- a/packages/frontend/src/components/MkDateSeparatedList.vue +++ b/packages/frontend/src/components/MkDateSeparatedList.vue @@ -33,6 +33,11 @@ export default defineComponent({ required: false, default: false, }, + getDate: { + type: Function, // Note => date string + required: false, + default: undefined, + } }, setup(props, { slots, expose }) { @@ -58,7 +63,7 @@ export default defineComponent({ if ( i !== props.items.length - 1 && - new Date(item.createdAt).getDate() !== new Date(props.items[i + 1].createdAt).getDate() + new Date(getDateKey(item)).getDate() !== new Date(getDateKey(props.items[i + 1])).getDate() ) { const separator = h('div', { class: $style['separator'], @@ -72,12 +77,12 @@ export default defineComponent({ h('i', { class: `ti ti-chevron-up ${$style['date-1-icon']}`, }), - getDateText(item.createdAt), + getDateText(getDateKey(item)), ]), h('span', { class: $style['date-2'], }, [ - getDateText(props.items[i + 1].createdAt), + getDateText(getDateKey(props.items[i + 1])), h('i', { class: `ti ti-chevron-down ${$style['date-2-icon']}`, }), @@ -97,6 +102,8 @@ export default defineComponent({ } }); + const getDateKey = (item: MisskeyEntity): string => props.getDate ? props.getDate(item) : item.createdAt; + const renderChildren = () => { const children = renderChildrenImpl(); if (isDebuggerEnabled(6864)) { diff --git a/packages/frontend/src/components/MkEvent.stories.impl.ts b/packages/frontend/src/components/MkEvent.stories.impl.ts new file mode 100644 index 0000000000..a60b40aa00 --- /dev/null +++ b/packages/frontend/src/components/MkEvent.stories.impl.ts @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import MkEvent from './MkEvent.vue'; +export const Default = { + render(args) { + return { + components: { + MkEvent, + }, + setup() { + return { + args, + }; + }, + beforeMount () { + document.body.style.background = 'var(--panel)'; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '', + }; + }, + args: { + note: { + event: { + title: 'Come on a Tea Party!', + start: '2017-10-25T15:00:00+0900', + end: '2017-10-25T18:00:00+0900', + metadata: { + '@context': 'https://schema.org', + '@type': 'Event', + location: 'Kawasaki, Japan', + description: 'Let\'s have a tea party!', + }, + }, + }, + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkEvent.vue b/packages/frontend/src/components/MkEvent.vue new file mode 100644 index 0000000000..c95325c4cb --- /dev/null +++ b/packages/frontend/src/components/MkEvent.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/packages/frontend/src/components/MkEventEditor.vue b/packages/frontend/src/components/MkEventEditor.vue new file mode 100644 index 0000000000..6fa5dca6fa --- /dev/null +++ b/packages/frontend/src/components/MkEventEditor.vue @@ -0,0 +1,272 @@ + + + + + diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index ad74d628ed..870ccf82e7 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -55,6 +55,7 @@
+

@@ -171,6 +172,7 @@ import MkCwButton from '@/components/MkCwButton.vue'; import MkPoll from '@/components/MkPoll.vue'; import MkUsersTooltip from '@/components/MkUsersTooltip.vue'; import MkUrlPreview from '@/components/MkUrlPreview.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'; diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index a7a70d1367..951a77d6c9 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -76,6 +76,7 @@

+

@@ -180,6 +181,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 { checkWordMute } from '@/scripts/check-word-mute'; import { userPage } from '@/filters/user'; diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue index b49c8fa8b7..5e5fd6cb1e 100644 --- a/packages/frontend/src/components/MkNotes.vue +++ b/packages/frontend/src/components/MkNotes.vue @@ -13,6 +13,7 @@ ref="notes" v-slot="{ item: note }" :items="notes" + :getDate="getDate" :direction="pagination.reversed ? 'up' : 'down'" :reversed="pagination.reversed" :noGap="noGap" @@ -37,6 +38,7 @@ import { infoImageUrl } from '@/instance'; const props = defineProps<{ pagination: Paging; noGap?: boolean; + getDate?: (any) => string; // custom function to separate notes on something that isn't createdAt }>(); const pagingComponent = shallowRef>(); diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 8a13836171..d03d525250 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -49,6 +49,7 @@

{{ i18n.ts.quoteAttached }}
+
{{ i18n.ts.recipient }}
@@ -75,6 +76,7 @@
+ @@ -104,6 +106,7 @@ import MkNoteSimple from '@/components/MkNoteSimple.vue'; import MkNotePreview from '@/components/MkNotePreview.vue'; import XPostFormAttaches from '@/components/MkPostFormAttaches.vue'; import MkPollEditor from '@/components/MkPollEditor.vue'; +import MkEventEditor from '@/components/MkEventEditor.vue'; import { host, url } from '@/config'; import { erase, unique } from '@/scripts/array'; import { extractMentions } from '@/scripts/extract-mentions'; @@ -166,6 +169,12 @@ let poll = $ref<{ expiresAt: string | null; expiredAfter: string | null; } | null>(null); +let event = $ref<{ + title: string; + start: string; + end: string | null; + metadata: Record; +} | null>(null); let useCw = $ref(false); let showPreview = $ref(defaultStore.state.rememberPostFormToggleStateEnabled ? defaultStore.state.showPostFormPreview : false); let cw = $ref(null); @@ -237,7 +246,7 @@ const maxTextLength = $computed((): number => { const canPost = $computed((): boolean => { return !posting && !posted && - (1 <= textLength || 1 <= files.length || !!poll || !!props.renote) && + (1 <= textLength || 1 <= files.length || !!poll || !!props.renote || !!event) && (textLength <= maxTextLength) && (!poll || poll.choices.length >= 2); }); @@ -343,6 +352,7 @@ function watchForDraft() { watch($$(cw), () => saveDraft()); watch($$(disableRightClick), () => saveDraft()); watch($$(poll), () => saveDraft()); + watch($$(event), () => saveDraft()); watch($$(files), () => saveDraft(), { deep: true }); watch($$(visibility), () => saveDraft()); watch($$(localOnly), () => saveDraft()); @@ -387,6 +397,19 @@ function togglePoll() { } } +function toggleEvent() { + if (event) { + event = null; + } else { + event = { + title: '', + start: (new Date()).toString(), + end: null, + metadata: {}, + }; + } +} + function addTag(tag: string) { insertTextAtCursor(textareaEl, ` #${tag} `); } @@ -527,6 +550,7 @@ function clear() { text = ''; files = []; poll = null; + event = null; quoteId = null; } @@ -654,6 +678,7 @@ function saveDraft() { localOnly: localOnly, files: files, poll: poll, + event: event, }, }; @@ -715,6 +740,7 @@ async function post(ev?: MouseEvent) { renoteId: props.renote ? props.renote.id : quoteId ? quoteId : undefined, channelId: props.channel ? props.channel.id : undefined, poll: poll, + event: event, cw: useCw ? cw ?? '' : undefined, localOnly: localOnly, visibility: visibility, @@ -878,6 +904,9 @@ onMounted(() => { if (draft.data.poll) { poll = draft.data.poll; } + if (draft.data.event) { + event = draft.data.event; + } } } @@ -896,6 +925,14 @@ onMounted(() => { expiredAfter: init.poll.expiredAfter, }; } + if (init.event) { + event = { + title: init.event.title, + start: init.event.start, + end: init.event.end, + metadata: init.event.metadata, + }; + } visibility = init.visibility; localOnly = init.localOnly; quoteId = init.renote ? init.renote.id : null; diff --git a/packages/frontend/src/pages/search.event.vue b/packages/frontend/src/pages/search.event.vue new file mode 100644 index 0000000000..d5b0dce1e1 --- /dev/null +++ b/packages/frontend/src/pages/search.event.vue @@ -0,0 +1,113 @@ + + + diff --git a/packages/frontend/src/pages/search.vue b/packages/frontend/src/pages/search.vue index dcaf42e648..26a9138dab 100644 --- a/packages/frontend/src/pages/search.vue +++ b/packages/frontend/src/pages/search.vue @@ -14,6 +14,10 @@ + + + + @@ -28,6 +32,7 @@ import MkInfo from '@/components/MkInfo.vue'; const XNote = defineAsyncComponent(() => import('./search.note.vue')); const XUser = defineAsyncComponent(() => import('./search.user.vue')); +const XEvent = defineAsyncComponent(() => import('./search.event.vue')); let tab = $ref('note'); @@ -43,6 +48,10 @@ const headerTabs = $computed(() => [{ key: 'user', title: i18n.ts.users, icon: 'ti ti-users', +}, { + key: 'event', + title: i18n.ts.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 new file mode 100644 index 0000000000..bb012e0533 --- /dev/null +++ b/packages/frontend/src/pages/user/events.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue index 538c6cf7cf..7fba4a79ed 100644 --- a/packages/frontend/src/pages/user/index.vue +++ b/packages/frontend/src/pages/user/index.vue @@ -2,9 +2,25 @@
+ +
+ + + + + + + + + +
+ + +
+ @@ -31,6 +47,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 +91,10 @@ const headerTabs = $computed(() => user ? [{ key: 'notes', title: i18n.ts.notes, icon: 'ti ti-pencil', +}, { + key: 'events', + title: i18n.ts.events, + icon: 'ti ti-calendar', }, { key: 'activity', title: i18n.ts.activity,