Merge pull request #10628 from misskey-dev/develop
This commit is contained in:
commit
c1f6c3ac83
|
@ -118,6 +118,7 @@ Meilisearchの設定に`index`が必要になりました。値はMisskeyサー
|
||||||
- Node.js 18.16.0以上が必要になりました
|
- Node.js 18.16.0以上が必要になりました
|
||||||
|
|
||||||
### General
|
### 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を全文検索に使用できるようになりました
|
- Meilisearchを全文検索に使用できるようになりました
|
||||||
* 「フォロワーのみ」の投稿は検索結果に表示されません。
|
* 「フォロワーのみ」の投稿は検索結果に表示されません。
|
||||||
|
|
|
@ -44,6 +44,7 @@
|
||||||
- 노트 작성 폼에서 본문 미리보기 상태 기억 ([shrimpia/misskey](https://github.com/shrimpia/misskey))
|
- 노트 작성 폼에서 본문 미리보기 상태 기억 ([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))
|
- 리모트에 존재하는 커스텀 이모지도 자신의 서버 내에 같은 이름의 이모지가 있으면 리액션 할 수 있도록 ([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
|
### Client
|
||||||
- (Friendly) 일부 페이지를 제외하고 플로팅 버튼을 표시하지 않음
|
- (Friendly) 일부 페이지를 제외하고 플로팅 버튼을 표시하지 않음
|
||||||
|
|
|
@ -1065,6 +1065,9 @@ accountMoved: "This user has moved to a new account:"
|
||||||
accountMovedShort: "This account has been migrated."
|
accountMovedShort: "This account has been migrated."
|
||||||
operationForbidden: "Operation forbidden"
|
operationForbidden: "Operation forbidden"
|
||||||
forceShowAds: "Always show ads"
|
forceShowAds: "Always show ads"
|
||||||
|
event: "Event"
|
||||||
|
events: "Events"
|
||||||
|
reverseChronological: "flashback"
|
||||||
addMemo: "Add memo"
|
addMemo: "Add memo"
|
||||||
editMemo: "Edit memo"
|
editMemo: "Edit memo"
|
||||||
reactionsList: "Reactions"
|
reactionsList: "Reactions"
|
||||||
|
@ -1155,6 +1158,34 @@ _initialAccountSetting:
|
||||||
laterAreYouSure: "Really do profile setup later?"
|
laterAreYouSure: "Really do profile setup later?"
|
||||||
_serverRules:
|
_serverRules:
|
||||||
description: "A set of rules to be displayed before registration. Setting a summary of the Terms of Service is recommended."
|
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:
|
_accountMigration:
|
||||||
moveFrom: "Migrate another account to this one"
|
moveFrom: "Migrate another account to this one"
|
||||||
moveFromSub: "Create alias to another account"
|
moveFromSub: "Create alias to another account"
|
||||||
|
|
32
locales/index.d.ts
vendored
32
locales/index.d.ts
vendored
|
@ -1068,6 +1068,9 @@ export interface Locale {
|
||||||
"accountMovedShort": string;
|
"accountMovedShort": string;
|
||||||
"operationForbidden": string;
|
"operationForbidden": string;
|
||||||
"forceShowAds": string;
|
"forceShowAds": string;
|
||||||
|
"event": string;
|
||||||
|
"events": string;
|
||||||
|
"reverseChronological": string;
|
||||||
"addMemo": string;
|
"addMemo": string;
|
||||||
"editMemo": string;
|
"editMemo": string;
|
||||||
"reactionsList": string;
|
"reactionsList": string;
|
||||||
|
@ -1164,6 +1167,35 @@ export interface Locale {
|
||||||
"_serverRules": {
|
"_serverRules": {
|
||||||
"description": string;
|
"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": {
|
"_accountMigration": {
|
||||||
"moveFrom": string;
|
"moveFrom": string;
|
||||||
"moveFromSub": string;
|
"moveFromSub": string;
|
||||||
|
|
|
@ -1065,6 +1065,9 @@ accountMoved: "このユーザーは新しいアカウントに移行しまし
|
||||||
accountMovedShort: "このアカウントは移行されています"
|
accountMovedShort: "このアカウントは移行されています"
|
||||||
operationForbidden: "この操作はできません"
|
operationForbidden: "この操作はできません"
|
||||||
forceShowAds: "常に広告を表示する"
|
forceShowAds: "常に広告を表示する"
|
||||||
|
event: "イベント"
|
||||||
|
events: "イベント"
|
||||||
|
reverseChronological: "倒叙"
|
||||||
addMemo: "メモを追加"
|
addMemo: "メモを追加"
|
||||||
editMemo: "メモを編集"
|
editMemo: "メモを編集"
|
||||||
reactionsList: "リアクション一覧"
|
reactionsList: "リアクション一覧"
|
||||||
|
@ -1162,6 +1165,35 @@ _initialAccountSetting:
|
||||||
_serverRules:
|
_serverRules:
|
||||||
description: "新規登録前に表示する、サーバーの簡潔なルールを設定します。内容は利用規約の要約とすることを推奨します。"
|
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:
|
_accountMigration:
|
||||||
moveFrom: "別のアカウントからこのアカウントに移行"
|
moveFrom: "別のアカウントからこのアカウントに移行"
|
||||||
moveFromSub: "別のアカウントへエイリアスを作成"
|
moveFromSub: "別のアカウントへエイリアスを作成"
|
||||||
|
|
|
@ -1066,6 +1066,9 @@ accountMoved: "이 사용자는 다음 계정으로 이사했어요:"
|
||||||
accountMovedShort: "이사한 계정이에요"
|
accountMovedShort: "이사한 계정이에요"
|
||||||
operationForbidden: "사용할 수 없어요"
|
operationForbidden: "사용할 수 없어요"
|
||||||
forceShowAds: "광고를 항상 표시"
|
forceShowAds: "광고를 항상 표시"
|
||||||
|
event: "이벤트"
|
||||||
|
events: "이벤트"
|
||||||
|
reverseChronological: "미래순"
|
||||||
addMemo: "메모 추가"
|
addMemo: "메모 추가"
|
||||||
editMemo: "메모 편집"
|
editMemo: "메모 편집"
|
||||||
reactionsList: "리액션 목록"
|
reactionsList: "리액션 목록"
|
||||||
|
@ -1155,6 +1158,34 @@ _initialAccountSetting:
|
||||||
laterAreYouSure: "초기 설정을 나중에 진행할까요?"
|
laterAreYouSure: "초기 설정을 나중에 진행할까요?"
|
||||||
_serverRules:
|
_serverRules:
|
||||||
description: "회원 가입 이전에 간단하게 표시할 서버 규칙이에요. 이용 약관의 요약으로 구성하는 것을 추천해요."
|
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:
|
_accountMigration:
|
||||||
moveFrom: "다른 계정에서 이 계정으로 이사"
|
moveFrom: "다른 계정에서 이 계정으로 이사"
|
||||||
moveFromSub: "다른 계정에 대한 별칭을 생성"
|
moveFromSub: "다른 계정에 대한 별칭을 생성"
|
||||||
|
|
91
packages/backend/migration/1681429921400-Event.js
Normal file
91
packages/backend/migration/1681429921400-Event.js
Normal file
|
@ -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") `);
|
||||||
|
}
|
||||||
|
}
|
29
packages/backend/migration/1681673280586-event.js
Normal file
29
packages/backend/migration/1681673280586-event.js
Normal file
|
@ -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`);
|
||||||
|
}
|
||||||
|
}
|
29
packages/backend/migration/1681675881633-event.js
Normal file
29
packages/backend/migration/1681675881633-event.js
Normal file
|
@ -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"`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -118,6 +118,7 @@ import { ApMentionService } from './activitypub/models/ApMentionService.js';
|
||||||
import { ApNoteService } from './activitypub/models/ApNoteService.js';
|
import { ApNoteService } from './activitypub/models/ApNoteService.js';
|
||||||
import { ApPersonService } from './activitypub/models/ApPersonService.js';
|
import { ApPersonService } from './activitypub/models/ApPersonService.js';
|
||||||
import { ApQuestionService } from './activitypub/models/ApQuestionService.js';
|
import { ApQuestionService } from './activitypub/models/ApQuestionService.js';
|
||||||
|
import { ApEventService } from './activitypub/models/ApEventService.js';
|
||||||
import { QueueModule } from './QueueModule.js';
|
import { QueueModule } from './QueueModule.js';
|
||||||
import { QueueService } from './QueueService.js';
|
import { QueueService } from './QueueService.js';
|
||||||
import { LoggerService } from './LoggerService.js';
|
import { LoggerService } from './LoggerService.js';
|
||||||
|
@ -247,6 +248,7 @@ const $ApMentionService: Provider = { provide: 'ApMentionService', useExisting:
|
||||||
const $ApNoteService: Provider = { provide: 'ApNoteService', useExisting: ApNoteService };
|
const $ApNoteService: Provider = { provide: 'ApNoteService', useExisting: ApNoteService };
|
||||||
const $ApPersonService: Provider = { provide: 'ApPersonService', useExisting: ApPersonService };
|
const $ApPersonService: Provider = { provide: 'ApPersonService', useExisting: ApPersonService };
|
||||||
const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting: ApQuestionService };
|
const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting: ApQuestionService };
|
||||||
|
const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEventService };
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
@ -374,6 +376,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
ApNoteService,
|
ApNoteService,
|
||||||
ApPersonService,
|
ApPersonService,
|
||||||
ApQuestionService,
|
ApQuestionService,
|
||||||
|
ApEventService,
|
||||||
QueueService,
|
QueueService,
|
||||||
|
|
||||||
//#region 文字列ベースでのinjection用(循環参照対応のため)
|
//#region 文字列ベースでのinjection用(循環参照対応のため)
|
||||||
|
@ -497,6 +500,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
$ApNoteService,
|
$ApNoteService,
|
||||||
$ApPersonService,
|
$ApPersonService,
|
||||||
$ApQuestionService,
|
$ApQuestionService,
|
||||||
|
$ApEventService,
|
||||||
//#endregion
|
//#endregion
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
|
@ -620,6 +624,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
ApNoteService,
|
ApNoteService,
|
||||||
ApPersonService,
|
ApPersonService,
|
||||||
ApQuestionService,
|
ApQuestionService,
|
||||||
|
ApEventService,
|
||||||
QueueService,
|
QueueService,
|
||||||
|
|
||||||
//#region 文字列ベースでのinjection用(循環参照対応のため)
|
//#region 文字列ベースでのinjection用(循環参照対応のため)
|
||||||
|
@ -742,6 +747,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
$ApNoteService,
|
$ApNoteService,
|
||||||
$ApPersonService,
|
$ApPersonService,
|
||||||
$ApQuestionService,
|
$ApQuestionService,
|
||||||
|
$ApEventService,
|
||||||
//#endregion
|
//#endregion
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf
|
||||||
import { extractHashtags } from '@/misc/extract-hashtags.js';
|
import { extractHashtags } from '@/misc/extract-hashtags.js';
|
||||||
import type { IMentionedRemoteUsers } from '@/models/entities/Note.js';
|
import type { IMentionedRemoteUsers } from '@/models/entities/Note.js';
|
||||||
import { Note } 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 { ChannelFollowingsRepository, ChannelsRepository, InstancesRepository, MutedNotesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
|
||||||
import type { DriveFile } from '@/models/entities/DriveFile.js';
|
import type { DriveFile } from '@/models/entities/DriveFile.js';
|
||||||
import type { App } from '@/models/entities/App.js';
|
import type { App } from '@/models/entities/App.js';
|
||||||
|
@ -128,6 +129,7 @@ type Option = {
|
||||||
renote?: Note | null;
|
renote?: Note | null;
|
||||||
files?: DriveFile[] | null;
|
files?: DriveFile[] | null;
|
||||||
poll?: IPoll | null;
|
poll?: IPoll | null;
|
||||||
|
event?: IEvent | null;
|
||||||
localOnly?: boolean | null;
|
localOnly?: boolean | null;
|
||||||
reactionAcceptance?: Note['reactionAcceptance'];
|
reactionAcceptance?: Note['reactionAcceptance'];
|
||||||
disableRightClick?: boolean | null;
|
disableRightClick?: boolean | null;
|
||||||
|
@ -364,6 +366,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
text: data.text,
|
text: data.text,
|
||||||
hasPoll: data.poll != null,
|
hasPoll: data.poll != null,
|
||||||
|
hasEvent: data.event != null,
|
||||||
cw: data.cw == null ? null : data.cw,
|
cw: data.cw == null ? null : data.cw,
|
||||||
tags: tags.map(tag => normalizeForSearch(tag)),
|
tags: tags.map(tag => normalizeForSearch(tag)),
|
||||||
emojis,
|
emojis,
|
||||||
|
@ -409,11 +412,12 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
|
|
||||||
// 投稿を作成
|
// 投稿を作成
|
||||||
try {
|
try {
|
||||||
if (insert.hasPoll) {
|
if (insert.hasPoll || insert.hasEvent) {
|
||||||
// Start transaction
|
// Start transaction
|
||||||
await this.db.transaction(async transactionalEntityManager => {
|
await this.db.transaction(async transactionalEntityManager => {
|
||||||
await transactionalEntityManager.insert(Note, insert);
|
await transactionalEntityManager.insert(Note, insert);
|
||||||
|
|
||||||
|
if (insert.hasPoll) {
|
||||||
const poll = new Poll({
|
const poll = new Poll({
|
||||||
noteId: insert.id,
|
noteId: insert.id,
|
||||||
choices: data.poll!.choices,
|
choices: data.poll!.choices,
|
||||||
|
@ -426,6 +430,22 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
});
|
});
|
||||||
|
|
||||||
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 {
|
} else {
|
||||||
await this.notesRepository.insert(insert);
|
await this.notesRepository.insert(insert);
|
||||||
|
|
|
@ -20,7 +20,7 @@ import { MfmService } from '@/core/MfmService.js';
|
||||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||||
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
||||||
import type { UserKeypair } from '@/models/entities/UserKeypair.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 { bindThis } from '@/decorators.js';
|
||||||
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
|
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
|
||||||
import { isNotNull } from '@/misc/is-not-null.js';
|
import { isNotNull } from '@/misc/is-not-null.js';
|
||||||
|
@ -53,6 +53,9 @@ export class ApRendererService {
|
||||||
@Inject(DI.pollsRepository)
|
@Inject(DI.pollsRepository)
|
||||||
private pollsRepository: PollsRepository,
|
private pollsRepository: PollsRepository,
|
||||||
|
|
||||||
|
@Inject(DI.eventsRepository)
|
||||||
|
private eventsRepository: EventsRepository,
|
||||||
|
|
||||||
private customEmojiService: CustomEmojiService,
|
private customEmojiService: CustomEmojiService,
|
||||||
private userEntityService: UserEntityService,
|
private userEntityService: UserEntityService,
|
||||||
private driveFileEntityService: DriveFileEntityService,
|
private driveFileEntityService: DriveFileEntityService,
|
||||||
|
@ -429,6 +432,17 @@ export class ApRendererService {
|
||||||
_misskey_talk: true,
|
_misskey_talk: true,
|
||||||
} as const : {};
|
} 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 {
|
return {
|
||||||
id: `${this.config.url}/notes/${note.id}`,
|
id: `${this.config.url}/notes/${note.id}`,
|
||||||
type: 'Note',
|
type: 'Note',
|
||||||
|
@ -449,6 +463,7 @@ export class ApRendererService {
|
||||||
attachment: files.map(x => this.renderDocument(x)),
|
attachment: files.map(x => this.renderDocument(x)),
|
||||||
sensitive: note.cw != null || files.some(file => file.isSensitive),
|
sensitive: note.cw != null || files.some(file => file.isSensitive),
|
||||||
tag,
|
tag,
|
||||||
|
...asEvent,
|
||||||
...asPoll,
|
...asPoll,
|
||||||
...asTalk,
|
...asTalk,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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<IEvent> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -31,6 +31,7 @@ import { ApPersonService } from './ApPersonService.js';
|
||||||
import { extractApHashtags } from './tag.js';
|
import { extractApHashtags } from './tag.js';
|
||||||
import { ApMentionService } from './ApMentionService.js';
|
import { ApMentionService } from './ApMentionService.js';
|
||||||
import { ApQuestionService } from './ApQuestionService.js';
|
import { ApQuestionService } from './ApQuestionService.js';
|
||||||
|
import { ApEventService } from './ApEventService.js';
|
||||||
import { ApImageService } from './ApImageService.js';
|
import { ApImageService } from './ApImageService.js';
|
||||||
import type { Resolver } from '../ApResolverService.js';
|
import type { Resolver } from '../ApResolverService.js';
|
||||||
import type { IObject, IPost } from '../type.js';
|
import type { IObject, IPost } from '../type.js';
|
||||||
|
@ -65,6 +66,7 @@ export class ApNoteService {
|
||||||
private apMentionService: ApMentionService,
|
private apMentionService: ApMentionService,
|
||||||
private apImageService: ApImageService,
|
private apImageService: ApImageService,
|
||||||
private apQuestionService: ApQuestionService,
|
private apQuestionService: ApQuestionService,
|
||||||
|
private apEventService: ApEventService,
|
||||||
private metaService: MetaService,
|
private metaService: MetaService,
|
||||||
private messagingService: MessagingService,
|
private messagingService: MessagingService,
|
||||||
private appLockService: AppLockService,
|
private appLockService: AppLockService,
|
||||||
|
@ -295,6 +297,7 @@ export class ApNoteService {
|
||||||
const apEmojis = emojis.map(emoji => emoji.name);
|
const apEmojis = emojis.map(emoji => emoji.name);
|
||||||
|
|
||||||
const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined);
|
const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined);
|
||||||
|
const event = await this.apEventService.extractEventFromNote(note, resolver).catch(() => undefined);
|
||||||
|
|
||||||
if (isMessaging) {
|
if (isMessaging) {
|
||||||
for (const recipient of visibleUsers) {
|
for (const recipient of visibleUsers) {
|
||||||
|
@ -319,6 +322,7 @@ export class ApNoteService {
|
||||||
apHashtags,
|
apHashtags,
|
||||||
apEmojis,
|
apEmojis,
|
||||||
poll,
|
poll,
|
||||||
|
event,
|
||||||
uri: note.id,
|
uri: note.id,
|
||||||
url: url,
|
url: url,
|
||||||
}, silent);
|
}, silent);
|
||||||
|
|
|
@ -134,6 +134,9 @@ export interface IQuestion extends IObject {
|
||||||
export const isQuestion = (object: IObject): object is IQuestion =>
|
export const isQuestion = (object: IObject): object is IQuestion =>
|
||||||
getApType(object) === 'Note' || getApType(object) === 'Question';
|
getApType(object) === 'Note' || getApType(object) === 'Question';
|
||||||
|
|
||||||
|
export const isEvent = (object: IObject): object is IObject =>
|
||||||
|
getApType(object) === 'Note' || getApType(object) === 'Event';
|
||||||
|
|
||||||
interface IQuestionChoice {
|
interface IQuestionChoice {
|
||||||
name?: string;
|
name?: string;
|
||||||
replies?: ICollection;
|
replies?: ICollection;
|
||||||
|
|
|
@ -9,7 +9,7 @@ import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||||
import type { User } from '@/models/entities/User.js';
|
import type { User } from '@/models/entities/User.js';
|
||||||
import type { Note } from '@/models/entities/Note.js';
|
import type { Note } from '@/models/entities/Note.js';
|
||||||
import type { NoteReaction } from '@/models/entities/NoteReaction.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 { bindThis } from '@/decorators.js';
|
||||||
import { isNotNull } from '@/misc/is-not-null.js';
|
import { isNotNull } from '@/misc/is-not-null.js';
|
||||||
import type { OnModuleInit } from '@nestjs/common';
|
import type { OnModuleInit } from '@nestjs/common';
|
||||||
|
@ -43,6 +43,9 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
@Inject(DI.pollsRepository)
|
@Inject(DI.pollsRepository)
|
||||||
private pollsRepository: PollsRepository,
|
private pollsRepository: PollsRepository,
|
||||||
|
|
||||||
|
@Inject(DI.eventsRepository)
|
||||||
|
private eventsRepository: EventsRepository,
|
||||||
|
|
||||||
@Inject(DI.pollVotesRepository)
|
@Inject(DI.pollVotesRepository)
|
||||||
private pollVotesRepository: 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
|
@bindThis
|
||||||
private async populateMyReaction(note: Note, meId: User['id'], _hint_?: {
|
private async populateMyReaction(note: Note, meId: User['id'], _hint_?: {
|
||||||
myReactions: Map<Note['id'], NoteReaction | null>;
|
myReactions: Map<Note['id'], NoteReaction | null>;
|
||||||
|
@ -354,6 +368,7 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
}) : undefined,
|
}) : undefined,
|
||||||
|
|
||||||
poll: note.hasPoll ? this.populatePoll(note, meId) : undefined,
|
poll: note.hasPoll ? this.populatePoll(note, meId) : undefined,
|
||||||
|
event: note.hasEvent ? this.populateEvent(note) : undefined,
|
||||||
|
|
||||||
...(meId ? {
|
...(meId ? {
|
||||||
myReaction: this.populateMyReaction(note, meId, options?._hint_),
|
myReaction: this.populateMyReaction(note, meId, options?._hint_),
|
||||||
|
|
|
@ -37,6 +37,7 @@ export const DI = {
|
||||||
followRequestsRepository: Symbol('followRequestsRepository'),
|
followRequestsRepository: Symbol('followRequestsRepository'),
|
||||||
instancesRepository: Symbol('instancesRepository'),
|
instancesRepository: Symbol('instancesRepository'),
|
||||||
emojisRepository: Symbol('emojisRepository'),
|
emojisRepository: Symbol('emojisRepository'),
|
||||||
|
eventsRepository: Symbol('eventsRepository'),
|
||||||
driveFilesRepository: Symbol('driveFilesRepository'),
|
driveFilesRepository: Symbol('driveFilesRepository'),
|
||||||
driveFoldersRepository: Symbol('driveFoldersRepository'),
|
driveFoldersRepository: Symbol('driveFoldersRepository'),
|
||||||
metasRepository: Symbol('metasRepository'),
|
metasRepository: Symbol('metasRepository'),
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { DI } from '@/di-symbols.js';
|
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 { DataSource } from 'typeorm';
|
||||||
import type { Provider } from '@nestjs/common';
|
import type { Provider } from '@nestjs/common';
|
||||||
|
|
||||||
|
@ -184,6 +184,12 @@ const $emojisRepository: Provider = {
|
||||||
inject: [DI.db],
|
inject: [DI.db],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const $eventsRepository: Provider = {
|
||||||
|
provide: DI.eventsRepository,
|
||||||
|
useFactory: (db: DataSource) => db.getRepository(Event),
|
||||||
|
inject: [DI.db],
|
||||||
|
};
|
||||||
|
|
||||||
const $driveFilesRepository: Provider = {
|
const $driveFilesRepository: Provider = {
|
||||||
provide: DI.driveFilesRepository,
|
provide: DI.driveFilesRepository,
|
||||||
useFactory: (db: DataSource) => db.getRepository(DriveFile),
|
useFactory: (db: DataSource) => db.getRepository(DriveFile),
|
||||||
|
@ -458,6 +464,7 @@ const $userMemosRepository: Provider = {
|
||||||
$followRequestsRepository,
|
$followRequestsRepository,
|
||||||
$instancesRepository,
|
$instancesRepository,
|
||||||
$emojisRepository,
|
$emojisRepository,
|
||||||
|
$eventsRepository,
|
||||||
$driveFilesRepository,
|
$driveFilesRepository,
|
||||||
$driveFoldersRepository,
|
$driveFoldersRepository,
|
||||||
$metasRepository,
|
$metasRepository,
|
||||||
|
@ -530,6 +537,7 @@ const $userMemosRepository: Provider = {
|
||||||
$followRequestsRepository,
|
$followRequestsRepository,
|
||||||
$instancesRepository,
|
$instancesRepository,
|
||||||
$emojisRepository,
|
$emojisRepository,
|
||||||
|
$eventsRepository,
|
||||||
$driveFilesRepository,
|
$driveFilesRepository,
|
||||||
$driveFoldersRepository,
|
$driveFoldersRepository,
|
||||||
$metasRepository,
|
$metasRepository,
|
||||||
|
|
120
packages/backend/src/models/entities/Event.ts
Normal file
120
packages/backend/src/models/entities/Event.ts
Normal file
|
@ -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<Event>) {
|
||||||
|
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;
|
||||||
|
}
|
|
@ -53,6 +53,11 @@ export class Note {
|
||||||
})
|
})
|
||||||
public threadId: string | null;
|
public threadId: string | null;
|
||||||
|
|
||||||
|
@Column('boolean', {
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
public hasEvent: boolean;
|
||||||
|
|
||||||
// TODO: varcharにしたい
|
// TODO: varcharにしたい
|
||||||
@Column('text', {
|
@Column('text', {
|
||||||
nullable: true,
|
nullable: true,
|
||||||
|
|
|
@ -16,6 +16,7 @@ import { ClipFavorite } from '@/models/entities/ClipFavorite.js';
|
||||||
import { DriveFile } from '@/models/entities/DriveFile.js';
|
import { DriveFile } from '@/models/entities/DriveFile.js';
|
||||||
import { DriveFolder } from '@/models/entities/DriveFolder.js';
|
import { DriveFolder } from '@/models/entities/DriveFolder.js';
|
||||||
import { Emoji } from '@/models/entities/Emoji.js';
|
import { Emoji } from '@/models/entities/Emoji.js';
|
||||||
|
import { Event } from '@/models/entities/Event.js';
|
||||||
import { Following } from '@/models/entities/Following.js';
|
import { Following } from '@/models/entities/Following.js';
|
||||||
import { FollowRequest } from '@/models/entities/FollowRequest.js';
|
import { FollowRequest } from '@/models/entities/FollowRequest.js';
|
||||||
import { GalleryLike } from '@/models/entities/GalleryLike.js';
|
import { GalleryLike } from '@/models/entities/GalleryLike.js';
|
||||||
|
@ -89,6 +90,7 @@ export {
|
||||||
DriveFile,
|
DriveFile,
|
||||||
DriveFolder,
|
DriveFolder,
|
||||||
Emoji,
|
Emoji,
|
||||||
|
Event,
|
||||||
Following,
|
Following,
|
||||||
FollowRequest,
|
FollowRequest,
|
||||||
GalleryLike,
|
GalleryLike,
|
||||||
|
@ -161,6 +163,7 @@ export type ClipFavoritesRepository = Repository<ClipFavorite>;
|
||||||
export type DriveFilesRepository = Repository<DriveFile>;
|
export type DriveFilesRepository = Repository<DriveFile>;
|
||||||
export type DriveFoldersRepository = Repository<DriveFolder>;
|
export type DriveFoldersRepository = Repository<DriveFolder>;
|
||||||
export type EmojisRepository = Repository<Emoji>;
|
export type EmojisRepository = Repository<Emoji>;
|
||||||
|
export type EventsRepository = Repository<Event>;
|
||||||
export type FollowingsRepository = Repository<Following>;
|
export type FollowingsRepository = Repository<Following>;
|
||||||
export type FollowRequestsRepository = Repository<FollowRequest>;
|
export type FollowRequestsRepository = Repository<FollowRequest>;
|
||||||
export type GalleryLikesRepository = Repository<GalleryLike>;
|
export type GalleryLikesRepository = Repository<GalleryLike>;
|
||||||
|
|
|
@ -24,6 +24,7 @@ import { ClipFavorite } from '@/models/entities/ClipFavorite.js';
|
||||||
import { DriveFile } from '@/models/entities/DriveFile.js';
|
import { DriveFile } from '@/models/entities/DriveFile.js';
|
||||||
import { DriveFolder } from '@/models/entities/DriveFolder.js';
|
import { DriveFolder } from '@/models/entities/DriveFolder.js';
|
||||||
import { Emoji } from '@/models/entities/Emoji.js';
|
import { Emoji } from '@/models/entities/Emoji.js';
|
||||||
|
import { Event } from '@/models/entities/Event.js';
|
||||||
import { Following } from '@/models/entities/Following.js';
|
import { Following } from '@/models/entities/Following.js';
|
||||||
import { FollowRequest } from '@/models/entities/FollowRequest.js';
|
import { FollowRequest } from '@/models/entities/FollowRequest.js';
|
||||||
import { GalleryLike } from '@/models/entities/GalleryLike.js';
|
import { GalleryLike } from '@/models/entities/GalleryLike.js';
|
||||||
|
@ -165,6 +166,7 @@ export const entities = [
|
||||||
Poll,
|
Poll,
|
||||||
PollVote,
|
PollVote,
|
||||||
Emoji,
|
Emoji,
|
||||||
|
Event,
|
||||||
Hashtag,
|
Hashtag,
|
||||||
SwSubscription,
|
SwSubscription,
|
||||||
AbuseUserReport,
|
AbuseUserReport,
|
||||||
|
|
|
@ -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_mentions from './endpoints/notes/mentions.js';
|
||||||
import * as ep___notes_polls_recommendation from './endpoints/notes/polls/recommendation.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_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 from './endpoints/notes/reactions.js';
|
||||||
import * as ep___notes_reactions_create from './endpoints/notes/reactions/create.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';
|
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_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_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_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: 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_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 };
|
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_mentions,
|
||||||
$notes_polls_recommendation,
|
$notes_polls_recommendation,
|
||||||
$notes_polls_vote,
|
$notes_polls_vote,
|
||||||
|
$notes_events_search,
|
||||||
$notes_reactions,
|
$notes_reactions,
|
||||||
$notes_reactions_create,
|
$notes_reactions_create,
|
||||||
$notes_reactions_delete,
|
$notes_reactions_delete,
|
||||||
|
@ -1348,6 +1351,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
||||||
$notes_mentions,
|
$notes_mentions,
|
||||||
$notes_polls_recommendation,
|
$notes_polls_recommendation,
|
||||||
$notes_polls_vote,
|
$notes_polls_vote,
|
||||||
|
$notes_events_search,
|
||||||
$notes_reactions,
|
$notes_reactions,
|
||||||
$notes_reactions_create,
|
$notes_reactions_create,
|
||||||
$notes_reactions_delete,
|
$notes_reactions_delete,
|
||||||
|
|
|
@ -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_mentions from './endpoints/notes/mentions.js';
|
||||||
import * as ep___notes_polls_recommendation from './endpoints/notes/polls/recommendation.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_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 from './endpoints/notes/reactions.js';
|
||||||
import * as ep___notes_reactions_create from './endpoints/notes/reactions/create.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';
|
import * as ep___notes_reactions_delete from './endpoints/notes/reactions/delete.js';
|
||||||
|
@ -623,6 +624,7 @@ const eps = [
|
||||||
['notes/mentions', ep___notes_mentions],
|
['notes/mentions', ep___notes_mentions],
|
||||||
['notes/polls/recommendation', ep___notes_polls_recommendation],
|
['notes/polls/recommendation', ep___notes_polls_recommendation],
|
||||||
['notes/polls/vote', ep___notes_polls_vote],
|
['notes/polls/vote', ep___notes_polls_vote],
|
||||||
|
['notes/events/search', ep___notes_events_search],
|
||||||
['notes/reactions', ep___notes_reactions],
|
['notes/reactions', ep___notes_reactions],
|
||||||
['notes/reactions/create', ep___notes_reactions_create],
|
['notes/reactions/create', ep___notes_reactions_create],
|
||||||
['notes/reactions/delete', ep___notes_reactions_delete],
|
['notes/reactions/delete', ep___notes_reactions_delete],
|
||||||
|
|
|
@ -147,6 +147,16 @@ export const paramDef = {
|
||||||
},
|
},
|
||||||
required: ['choices'],
|
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
|
// (re)note with text, files and poll are optional
|
||||||
anyOf: [
|
anyOf: [
|
||||||
|
@ -282,6 +292,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||||
text: ps.text ?? undefined,
|
text: ps.text ?? undefined,
|
||||||
reply,
|
reply,
|
||||||
renote,
|
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,
|
cw: ps.cw,
|
||||||
localOnly: ps.localOnly,
|
localOnly: ps.localOnly,
|
||||||
reactionAcceptance: ps.reactionAcceptance,
|
reactionAcceptance: ps.reactionAcceptance,
|
||||||
|
|
171
packages/backend/src/server/api/endpoints/notes/events/search.ts
Normal file
171
packages/backend/src/server/api/endpoints/notes/events/search.ts
Normal file
|
@ -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<typeof meta, typeof paramDef> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -1677,6 +1677,12 @@ export type Endpoints = {
|
||||||
expiresAt?: null | number;
|
expiresAt?: null | number;
|
||||||
expiredAfter?: null | number;
|
expiredAfter?: null | number;
|
||||||
};
|
};
|
||||||
|
event?: null | {
|
||||||
|
title: string;
|
||||||
|
start: number;
|
||||||
|
end?: null | number;
|
||||||
|
metadata: Record<string, string>;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
res: {
|
res: {
|
||||||
createdNote: Note;
|
createdNote: Note;
|
||||||
|
@ -1754,6 +1760,24 @@ export type Endpoints = {
|
||||||
};
|
};
|
||||||
res: null;
|
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': {
|
'notes/reactions': {
|
||||||
req: {
|
req: {
|
||||||
noteId: Note['id'];
|
noteId: Note['id'];
|
||||||
|
@ -2406,6 +2430,12 @@ type Note = {
|
||||||
replyId: Note['id'];
|
replyId: Note['id'];
|
||||||
renote?: Note;
|
renote?: Note;
|
||||||
renoteId: Note['id'];
|
renoteId: Note['id'];
|
||||||
|
event?: {
|
||||||
|
title: string;
|
||||||
|
start: DateString;
|
||||||
|
end: DateString | null;
|
||||||
|
metadata: Record<string, string>;
|
||||||
|
};
|
||||||
files: DriveFile[];
|
files: DriveFile[];
|
||||||
fileIds: DriveFile['id'][];
|
fileIds: DriveFile['id'][];
|
||||||
visibility: 'public' | 'home' | 'followers' | 'specified';
|
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: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: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
|
// 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)
|
// (No @packageDocumentation comment for this package)
|
||||||
|
|
|
@ -488,6 +488,12 @@ export type Endpoints = {
|
||||||
expiresAt?: null | number;
|
expiresAt?: null | number;
|
||||||
expiredAfter?: null | number;
|
expiredAfter?: null | number;
|
||||||
};
|
};
|
||||||
|
event?: null | {
|
||||||
|
title: string;
|
||||||
|
start: number;
|
||||||
|
end?: null | number;
|
||||||
|
metadata: Record<string, string>;
|
||||||
|
}
|
||||||
}; res: { createdNote: Note }; };
|
}; res: { createdNote: Note }; };
|
||||||
'notes/delete': { req: { noteId: Note['id']; }; res: null; };
|
'notes/delete': { req: { noteId: Note['id']; }; res: null; };
|
||||||
'notes/favorites/create': { 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/mentions': { req: { following?: boolean; limit?: number; sinceId?: Note['id']; untilId?: Note['id']; }; res: Note[]; };
|
||||||
'notes/polls/recommendation': { req: TODO; res: TODO; };
|
'notes/polls/recommendation': { req: TODO; res: TODO; };
|
||||||
'notes/polls/vote': { req: { noteId: Note['id']; choice: number; }; res: null; };
|
'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': { req: { noteId: Note['id']; type?: string | null; limit?: number; }; res: NoteReaction[]; };
|
||||||
'notes/reactions/create': { req: { noteId: Note['id']; reaction: string; }; res: null; };
|
'notes/reactions/create': { req: { noteId: Note['id']; reaction: string; }; res: null; };
|
||||||
'notes/reactions/delete': { req: { noteId: Note['id']; }; res: null; };
|
'notes/reactions/delete': { req: { noteId: Note['id']; }; res: null; };
|
||||||
|
|
|
@ -150,6 +150,12 @@ export type Note = {
|
||||||
replyId: Note['id'];
|
replyId: Note['id'];
|
||||||
renote?: Note;
|
renote?: Note;
|
||||||
renoteId: Note['id'];
|
renoteId: Note['id'];
|
||||||
|
event?: {
|
||||||
|
title: string,
|
||||||
|
start: DateString,
|
||||||
|
end: DateString | null,
|
||||||
|
metadata: Record<string, string>,
|
||||||
|
};
|
||||||
files: DriveFile[];
|
files: DriveFile[];
|
||||||
fileIds: DriveFile['id'][];
|
fileIds: DriveFile['id'][];
|
||||||
visibility: 'public' | 'home' | 'followers' | 'specified';
|
visibility: 'public' | 'home' | 'followers' | 'specified';
|
||||||
|
|
|
@ -397,6 +397,7 @@ function toStories(component: string): string {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
glob('src/components/global/*.vue'),
|
glob('src/components/global/*.vue'),
|
||||||
glob('src/components/Mk{A,B}*.vue'),
|
glob('src/components/Mk{A,B}*.vue'),
|
||||||
|
glob('src/components/MkEvent.vue'),
|
||||||
glob('src/components/MkDigitalClock.vue'),
|
glob('src/components/MkDigitalClock.vue'),
|
||||||
glob('src/components/MkGalleryPostPreview.vue'),
|
glob('src/components/MkGalleryPostPreview.vue'),
|
||||||
glob('src/components/MkSignupServerRules.vue'),
|
glob('src/components/MkSignupServerRules.vue'),
|
||||||
|
|
|
@ -33,6 +33,11 @@ export default defineComponent({
|
||||||
required: false,
|
required: false,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
getDate: {
|
||||||
|
type: Function, // Note => date string
|
||||||
|
required: false,
|
||||||
|
default: undefined,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setup(props, { slots, expose }) {
|
setup(props, { slots, expose }) {
|
||||||
|
@ -58,7 +63,7 @@ export default defineComponent({
|
||||||
|
|
||||||
if (
|
if (
|
||||||
i !== props.items.length - 1 &&
|
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', {
|
const separator = h('div', {
|
||||||
class: $style['separator'],
|
class: $style['separator'],
|
||||||
|
@ -72,12 +77,12 @@ export default defineComponent({
|
||||||
h('i', {
|
h('i', {
|
||||||
class: `ti ti-chevron-up ${$style['date-1-icon']}`,
|
class: `ti ti-chevron-up ${$style['date-1-icon']}`,
|
||||||
}),
|
}),
|
||||||
getDateText(item.createdAt),
|
getDateText(getDateKey(item)),
|
||||||
]),
|
]),
|
||||||
h('span', {
|
h('span', {
|
||||||
class: $style['date-2'],
|
class: $style['date-2'],
|
||||||
}, [
|
}, [
|
||||||
getDateText(props.items[i + 1].createdAt),
|
getDateText(getDateKey(props.items[i + 1])),
|
||||||
h('i', {
|
h('i', {
|
||||||
class: `ti ti-chevron-down ${$style['date-2-icon']}`,
|
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 renderChildren = () => {
|
||||||
const children = renderChildrenImpl();
|
const children = renderChildrenImpl();
|
||||||
if (isDebuggerEnabled(6864)) {
|
if (isDebuggerEnabled(6864)) {
|
||||||
|
|
46
packages/frontend/src/components/MkEvent.stories.impl.ts
Normal file
46
packages/frontend/src/components/MkEvent.stories.impl.ts
Normal file
|
@ -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: '<MkEvent v-bind="props" />',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
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<typeof MkEvent>;
|
120
packages/frontend/src/components/MkEvent.vue
Normal file
120
packages/frontend/src/components/MkEvent.vue
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
<template>
|
||||||
|
<div :class="$style.container">
|
||||||
|
<div :class="$style.title">
|
||||||
|
<i class="ti ti-calendar-event icon"></i>
|
||||||
|
{{ note.event!.title }}
|
||||||
|
</div>
|
||||||
|
<dl :class="$style.details">
|
||||||
|
<dt :class="$style.key">{{ i18n.ts._event.startDateTime }}</dt>
|
||||||
|
<dd :class="$style.value">
|
||||||
|
<MkTime :time="note.event!.start" mode="detail"/>
|
||||||
|
</dd>
|
||||||
|
<template v-if="note.event!.end">
|
||||||
|
<dt :class="$style.key">{{ i18n.ts._event.endDateTime }}</dt>
|
||||||
|
<dd :class="$style.value">
|
||||||
|
<MkTime :time="note.event!.end" mode="detail"/>
|
||||||
|
</dd>
|
||||||
|
</template>
|
||||||
|
<template v-if="note.event!.metadata.doorTime">
|
||||||
|
<dt :class="$style.key">{{ i18n.ts._event.doorTime }}</dt>
|
||||||
|
<dd :class="$style.value">{{ note.event!.metadata.doorTime }}</dd>
|
||||||
|
</template>
|
||||||
|
<template v-if="note.event!.metadata.location">
|
||||||
|
<dt :class="$style.key">{{ i18n.ts._event.location }}</dt>
|
||||||
|
<dd :class="$style.value">{{ note.event!.metadata.location }}</dd>
|
||||||
|
</template>
|
||||||
|
<template v-if="note.event!.metadata.url">
|
||||||
|
<dt :class="$style.key">{{ i18n.ts._event.url }}</dt>
|
||||||
|
<dd :class="$style.value">{{ note.event!.metadata.url }}</dd>
|
||||||
|
</template>
|
||||||
|
<template v-if="note.event!.metadata.organizer">
|
||||||
|
<dt :class="$style.key">{{ i18n.ts._event.organizer }}</dt>
|
||||||
|
<dd :class="$style.value">{{ note.event!.metadata.organizer.name }}</dd>
|
||||||
|
</template>
|
||||||
|
<template v-if="note.event!.metadata.audience">
|
||||||
|
<dt :class="$style.key">{{ i18n.ts._event.audience }}</dt>
|
||||||
|
<dd :class="$style.value">{{ note.event!.metadata.audience.name }}</dd>
|
||||||
|
</template>
|
||||||
|
<template v-if="note.event!.metadata.inLanguage">
|
||||||
|
<dt :class="$style.key">{{ i18n.ts._event.language }}</dt>
|
||||||
|
<dd :class="$style.value">{{ note.event!.metadata.inLanguage }}</dd>
|
||||||
|
</template>
|
||||||
|
<template v-if="note.event!.metadata.typicalAgeRange">
|
||||||
|
<dt :class="$style.key">{{ i18n.ts._event.ageRange }}</dt>
|
||||||
|
<dd :class="$style.value">{{ note.event!.metadata.typicalAgeRange }}</dd>
|
||||||
|
</template>
|
||||||
|
<template v-if="note.event!.metadata.performer">
|
||||||
|
<dt :class="$style.key">{{ i18n.ts._event.performers }}</dt>
|
||||||
|
<dd :class="$style.value">{{ note.event!.metadata.performer.join(', ') }}</dd>
|
||||||
|
</template>
|
||||||
|
<template v-if="note.event!.metadata.offers?.url">
|
||||||
|
<dt :class="$style.key">{{ i18n.ts._event.ticketsUrl }}</dt>
|
||||||
|
<dd :class="$style.value">{{ note.event!.metadata.offers.url }}</dd>
|
||||||
|
</template>
|
||||||
|
<template v-if="note.event!.metadata.isAccessibleForFree">
|
||||||
|
<dt :class="$style.key">{{ i18n.ts._event.isFree }}</dt>
|
||||||
|
<dd :class="$style.value">{{ "Yes" }}</dd>
|
||||||
|
</template>
|
||||||
|
<template v-if="note.event!.metadata.offers?.price">
|
||||||
|
<dt :class="$style.key">{{ i18n.ts._event.price }}</dt>
|
||||||
|
<dd :class="$style.value">{{ note.event!.metadata.offers.price }}</dd>
|
||||||
|
</template>
|
||||||
|
<template v-if="note.event!.metadata.offers?.availabilityStarts || note.event!.metadata.offers?.availabilityEnds">
|
||||||
|
<dt :class="$style.key">{{ i18n.ts._event.availability }}</dt>
|
||||||
|
<dd :class="$style.value">
|
||||||
|
{{ [
|
||||||
|
(note.event!.metadata.offers.availabilityStarts ? i18n.ts._event.from + note.event!.metadata.offers.availabilityStarts : ''),
|
||||||
|
(note.event!.metadata.offers.availabilityEnds ? i18n.ts._event.until + note.event!.metadata.offers.availabilityEnds : '')]
|
||||||
|
.join(' ') }}
|
||||||
|
</dd>
|
||||||
|
</template>
|
||||||
|
<template v-if="note.event!.metadata.keywords">
|
||||||
|
<dt :class="$style.key">{{ i18n.ts._event.keywords }}</dt>
|
||||||
|
<dd :class="$style.value">{{ note.event!.metadata.keywords }}</dd>
|
||||||
|
</template>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import * as misskey from 'cherrypick-js';
|
||||||
|
import { i18n } from '@/i18n';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
note: misskey.entities.Note
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.container {
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
font-weight: bold;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: .5px solid var(--divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
grid-gap: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
</style>
|
272
packages/frontend/src/components/MkEventEditor.vue
Normal file
272
packages/frontend/src/components/MkEventEditor.vue
Normal file
|
@ -0,0 +1,272 @@
|
||||||
|
<template>
|
||||||
|
<div class="zmdxowut">
|
||||||
|
<MkInput v-model="title" small type="text" class="input">
|
||||||
|
<template #label>*{{ i18n.ts._event.title }}</template>
|
||||||
|
</MkInput>
|
||||||
|
<section>
|
||||||
|
<div>
|
||||||
|
<section>
|
||||||
|
<MkInput v-model="startDate" small type="date" class="input">
|
||||||
|
<template #label>*{{ i18n.ts._event.startDate }}</template>
|
||||||
|
</MkInput>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<MkInput v-model="startTime" small type="time" class="input">
|
||||||
|
<template #label>*{{ i18n.ts._event.startTime }}</template>
|
||||||
|
</MkInput>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<MkInput v-model="endDate" small type="date" class="input">
|
||||||
|
<template #label>{{ i18n.ts._event.endDate }}</template>
|
||||||
|
</MkInput>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<MkInput v-model="endTime" small type="time" class="input">
|
||||||
|
<template #label>{{ i18n.ts._event.endTime }}</template>
|
||||||
|
</MkInput>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<MkInput v-model="location" small type="text" class="input">
|
||||||
|
<template #label>{{ i18n.ts._event.location }}</template>
|
||||||
|
</MkInput>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<MkInput v-model="url" small type="url" class="input">
|
||||||
|
<template #label>{{ i18n.ts._event.url }}</template>
|
||||||
|
</MkInput>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<br/>
|
||||||
|
<div>
|
||||||
|
<section>
|
||||||
|
<MkInput v-model="doorTime" small type="time" class="input">
|
||||||
|
<template #label>{{ i18n.ts._event.doorTime }}</template>
|
||||||
|
</MkInput>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<MkInput v-model="organizer" small type="text" class="input">
|
||||||
|
<template #label>{{ i18n.ts._event.organizer }}</template>
|
||||||
|
</MkInput>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<MkInput v-model="organizerLink" small type="url" class="input">
|
||||||
|
<template #label>{{ i18n.ts._event.organizerLink }}</template>
|
||||||
|
</MkInput>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<MkInput v-model="audience" small type="text" class="input">
|
||||||
|
<template #label>{{ i18n.ts._event.audience }}</template>
|
||||||
|
</MkInput>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<MkInput v-model="language" small type="text" class="input">
|
||||||
|
<template #label>{{ i18n.ts._event.language }}</template>
|
||||||
|
</MkInput>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<MkInput v-model="ageRange" small type="text" class="input">
|
||||||
|
<template #label>{{ i18n.ts._event.ageRange }}</template>
|
||||||
|
</MkInput>
|
||||||
|
</section>
|
||||||
|
<!--<section>
|
||||||
|
<MkInput v-model="performers" small type="text" class="input">
|
||||||
|
<template #label>{{ i18n.ts._event.performers }}</template>
|
||||||
|
</MkInput>
|
||||||
|
</section>-->
|
||||||
|
<section>
|
||||||
|
<MkInput v-model="ticketsUrl" small type="url" class="input">
|
||||||
|
<template #label>{{ i18n.ts._event.ticketsUrl }}</template>
|
||||||
|
</MkInput>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<MkSwitch v-model="isFree" :disabled="false">
|
||||||
|
{{ i18n.ts._event.isFree }}
|
||||||
|
</MkSwitch>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<MkInput v-model="price" small type="text" class="input">
|
||||||
|
<template #label>{{ i18n.ts._event.price }}</template>
|
||||||
|
</MkInput>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<MkInput v-model="availabilityStart" small type="datetime-local" class="input">
|
||||||
|
<template #label>{{ i18n.ts._event.availabilityStart }}</template>
|
||||||
|
</MkInput>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<MkInput v-model="availabilityEnd" small type="datetime-local" class="input">
|
||||||
|
<template #label>{{ i18n.ts._event.availabilityEnd }}</template>
|
||||||
|
</MkInput>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<MkInput v-model="keywords" small type="text" class="input">
|
||||||
|
<template #label>{{ i18n.ts._event.keywords }}</template>
|
||||||
|
</MkInput>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import * as misskey from 'cherrypick-js';
|
||||||
|
import { Ref, ref, watch } from 'vue';
|
||||||
|
import MkInput from './MkInput.vue';
|
||||||
|
import MkSwitch from './MkSwitch.vue';
|
||||||
|
import { formatDateTimeString } from '@/scripts/format-time-string';
|
||||||
|
import { addTime } from '@/scripts/time';
|
||||||
|
import { i18n } from '@/i18n';
|
||||||
|
import date from '@/filters/date';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: misskey.entities.Note['event']
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(ev: 'update:modelValue', v: {
|
||||||
|
model: misskey.entities.Note['event']
|
||||||
|
})
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const title = ref(props.modelValue?.title ?? null);
|
||||||
|
const startDate = ref(formatDateTimeString(addTime(new Date(), 1, 'day'), 'yyyy-MM-dd'));
|
||||||
|
const startTime = ref('00:00');
|
||||||
|
const endDate = ref('');
|
||||||
|
const endTime = ref('');
|
||||||
|
const location = ref(props.modelValue?.metadata.location ?? null);
|
||||||
|
const url = ref(props.modelValue?.metadata.url ?? null);
|
||||||
|
const doorTime = ref(props.modelValue?.metadata.doorTime ?? null);
|
||||||
|
const organizer = ref(props.modelValue?.metadata.organizer?.name ?? null);
|
||||||
|
const organizerLink = ref(props.modelValue?.metadata.organizer?.sameAs ?? null);
|
||||||
|
const audience = ref(props.modelValue?.metadata.audience?.name ?? null);
|
||||||
|
const language = ref(props.modelValue?.metadata.inLanguage ?? null);
|
||||||
|
const ageRange = ref(props.modelValue?.metadata.typicalAgeRange ?? null);
|
||||||
|
const ticketsUrl = ref(props.modelValue?.metadata.offers?.url ?? null);
|
||||||
|
const isFree = ref(props.modelValue?.metadata.isAccessibleForFree ?? false);
|
||||||
|
const price = ref(props.modelValue?.metadata.offers?.price ?? null);
|
||||||
|
const availabilityStart = ref(props.modelValue?.metadata.offers?.availabilityStarts ?? null);
|
||||||
|
const availabilityEnd = ref(props.modelValue?.metadata.offers?.availabilityEnds ?? null);
|
||||||
|
const keywords = ref(props.modelValue?.metadata.keywords ?? null);
|
||||||
|
|
||||||
|
function get(): misskey.entities.Note['event'] {
|
||||||
|
const calcAt = (date: Ref<string>, time: Ref<string>): number => (new Date(`${date.value} ${time.value}`)).getTime();
|
||||||
|
|
||||||
|
const start = calcAt(startDate, startTime);
|
||||||
|
const end = endDate.value ? calcAt(endDate, endTime) : null;
|
||||||
|
return {
|
||||||
|
title: title.value,
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
metadata: {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Event',
|
||||||
|
name: title.value,
|
||||||
|
startDate: (new Date(start)).toISOString(),
|
||||||
|
endDate: end ? (new Date(end)).toISOString() : undefined,
|
||||||
|
location: location.value ?? undefined,
|
||||||
|
url: url.value ?? undefined,
|
||||||
|
doorTime: doorTime.value ?? undefined,
|
||||||
|
organizer: organizer.value ? {
|
||||||
|
'@type': 'Thing',
|
||||||
|
name: organizer.value,
|
||||||
|
sameAs: organizerLink.value ?? undefined,
|
||||||
|
} : undefined,
|
||||||
|
audience: audience.value ? {
|
||||||
|
'@type': 'Audience',
|
||||||
|
name: audience.value,
|
||||||
|
} : undefined,
|
||||||
|
inLanguage: language.value ?? undefined,
|
||||||
|
typicalAgeRange: ageRange.value ?? undefined,
|
||||||
|
offers: ticketsUrl.value || price.value ? {
|
||||||
|
price: price.value ?? undefined,
|
||||||
|
priceCurrency: undefined,
|
||||||
|
availabilityStarts: availabilityStart.value ?? undefined,
|
||||||
|
availabilityEnds: availabilityEnd.value ?? undefined,
|
||||||
|
url: ticketsUrl.value ?? undefined,
|
||||||
|
} : undefined,
|
||||||
|
keywords: keywords.value ?? undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([title, startDate, startTime, endDate, endTime, location, url, doorTime, organizer, organizerLink, audience, language,
|
||||||
|
ageRange, ticketsUrl, isFree, price, availabilityStart, availabilityEnd, keywords], () => emit('update:modelValue', get()), {
|
||||||
|
deep: true,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.zmdxowut {
|
||||||
|
padding: 8px 16px;
|
||||||
|
|
||||||
|
>.caution {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: #f00;
|
||||||
|
|
||||||
|
>i {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
>ul {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
|
||||||
|
>li {
|
||||||
|
display: flex;
|
||||||
|
margin: 8px 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
>.input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
>button {
|
||||||
|
width: 32px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
>.add {
|
||||||
|
margin: 8px 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
>section {
|
||||||
|
margin: 16px 0 0 0;
|
||||||
|
|
||||||
|
>div {
|
||||||
|
margin: 0 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
flex: 1 0 auto;
|
||||||
|
|
||||||
|
>div {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
>section {
|
||||||
|
// MAGIC: Prevent div above from growing unless wrapped to its own line
|
||||||
|
flex-grow: 9999;
|
||||||
|
align-items: end;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
>.input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -55,6 +55,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="container-type: inline-size;">
|
<div style="container-type: inline-size;">
|
||||||
|
<MkEvent v-if="appearNote.event" :note="appearNote"/>
|
||||||
<p v-if="appearNote.cw != null" :class="$style.cw">
|
<p v-if="appearNote.cw != null" :class="$style.cw">
|
||||||
<Mfm v-if="appearNote.cw != ''" :text="appearNote.cw" :author="appearNote.user" :i="$i"/>
|
<Mfm v-if="appearNote.cw != ''" :text="appearNote.cw" :author="appearNote.user" :i="$i"/>
|
||||||
<MkCwButton v-model="showContent" :note="appearNote"/>
|
<MkCwButton v-model="showContent" :note="appearNote"/>
|
||||||
|
@ -171,6 +172,7 @@ import MkCwButton from '@/components/MkCwButton.vue';
|
||||||
import MkPoll from '@/components/MkPoll.vue';
|
import MkPoll from '@/components/MkPoll.vue';
|
||||||
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
|
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
|
||||||
import MkUrlPreview from '@/components/MkUrlPreview.vue';
|
import MkUrlPreview from '@/components/MkUrlPreview.vue';
|
||||||
|
import MkEvent from '@/components/MkEvent.vue';
|
||||||
import { pleaseLogin } from '@/scripts/please-login';
|
import { pleaseLogin } from '@/scripts/please-login';
|
||||||
import { focusPrev, focusNext } from '@/scripts/focus';
|
import { focusPrev, focusNext } from '@/scripts/focus';
|
||||||
import { checkWordMute } from '@/scripts/check-word-mute';
|
import { checkWordMute } from '@/scripts/check-word-mute';
|
||||||
|
|
|
@ -76,6 +76,7 @@
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div :class="$style.noteContent">
|
<div :class="$style.noteContent">
|
||||||
|
<MkEvent v-if="appearNote.event" :note="appearNote"/>
|
||||||
<p v-if="appearNote.cw != null" :class="$style.cw">
|
<p v-if="appearNote.cw != null" :class="$style.cw">
|
||||||
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :i="$i"/>
|
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :i="$i"/>
|
||||||
<MkCwButton v-model="showContent" :note="appearNote"/>
|
<MkCwButton v-model="showContent" :note="appearNote"/>
|
||||||
|
@ -180,6 +181,7 @@ import MkPoll from '@/components/MkPoll.vue';
|
||||||
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
|
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
|
||||||
import MkUrlPreview from '@/components/MkUrlPreview.vue';
|
import MkUrlPreview from '@/components/MkUrlPreview.vue';
|
||||||
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
|
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
|
||||||
|
import MkEvent from '@/components/MkEvent.vue';
|
||||||
import { pleaseLogin } from '@/scripts/please-login';
|
import { pleaseLogin } from '@/scripts/please-login';
|
||||||
import { checkWordMute } from '@/scripts/check-word-mute';
|
import { checkWordMute } from '@/scripts/check-word-mute';
|
||||||
import { userPage } from '@/filters/user';
|
import { userPage } from '@/filters/user';
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
ref="notes"
|
ref="notes"
|
||||||
v-slot="{ item: note }"
|
v-slot="{ item: note }"
|
||||||
:items="notes"
|
:items="notes"
|
||||||
|
:getDate="getDate"
|
||||||
:direction="pagination.reversed ? 'up' : 'down'"
|
:direction="pagination.reversed ? 'up' : 'down'"
|
||||||
:reversed="pagination.reversed"
|
:reversed="pagination.reversed"
|
||||||
:noGap="noGap"
|
:noGap="noGap"
|
||||||
|
@ -37,6 +38,7 @@ import { infoImageUrl } from '@/instance';
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
pagination: Paging;
|
pagination: Paging;
|
||||||
noGap?: boolean;
|
noGap?: boolean;
|
||||||
|
getDate?: (any) => string; // custom function to separate notes on something that isn't createdAt
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
|
const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
|
||||||
|
|
|
@ -49,6 +49,7 @@
|
||||||
<MkNoteSimple v-if="reply" :class="$style.targetNote" :note="reply"/>
|
<MkNoteSimple v-if="reply" :class="$style.targetNote" :note="reply"/>
|
||||||
<MkNoteSimple v-if="renote" :class="$style.targetNote" :note="renote"/>
|
<MkNoteSimple v-if="renote" :class="$style.targetNote" :note="renote"/>
|
||||||
<div v-if="quoteId" :class="$style.withQuote"><i class="ti ti-quote"></i> {{ i18n.ts.quoteAttached }}<button @click="quoteId = null"><i class="ti ti-x"></i></button></div>
|
<div v-if="quoteId" :class="$style.withQuote"><i class="ti ti-quote"></i> {{ i18n.ts.quoteAttached }}<button @click="quoteId = null"><i class="ti ti-x"></i></button></div>
|
||||||
|
<MkEventEditor v-if="event" v-model="event" @destroyed="event = null"/>
|
||||||
<div v-if="visibility === 'specified'" :class="$style.toSpecified">
|
<div v-if="visibility === 'specified'" :class="$style.toSpecified">
|
||||||
<span style="margin-right: 8px;">{{ i18n.ts.recipient }}</span>
|
<span style="margin-right: 8px;">{{ i18n.ts.recipient }}</span>
|
||||||
<div :class="$style.visibleUsers">
|
<div :class="$style.visibleUsers">
|
||||||
|
@ -75,6 +76,7 @@
|
||||||
<div :class="$style.footerLeft">
|
<div :class="$style.footerLeft">
|
||||||
<button v-tooltip="i18n.ts.attachFile" class="_button" :class="$style.footerButton" @click="chooseFileFrom"><i class="ti ti-photo-plus"></i></button>
|
<button v-tooltip="i18n.ts.attachFile" class="_button" :class="$style.footerButton" @click="chooseFileFrom"><i class="ti ti-photo-plus"></i></button>
|
||||||
<button v-tooltip="i18n.ts.poll" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: poll }]" @click="togglePoll"><i class="ti ti-chart-arrows"></i></button>
|
<button v-tooltip="i18n.ts.poll" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: poll }]" @click="togglePoll"><i class="ti ti-chart-arrows"></i></button>
|
||||||
|
<button v-tooltip="i18n.ts.event" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: event }]" @click="toggleEvent"><i class="ti ti-calendar"></i></button>
|
||||||
<button v-tooltip="i18n.ts.useCw" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: useCw }]" @click="useCw = !useCw"><i class="ti ti-eye-off"></i></button>
|
<button v-tooltip="i18n.ts.useCw" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: useCw }]" @click="useCw = !useCw"><i class="ti ti-eye-off"></i></button>
|
||||||
<button v-tooltip="i18n.ts.mention" class="_button" :class="$style.footerButton" @click="insertMention"><i class="ti ti-at"></i></button>
|
<button v-tooltip="i18n.ts.mention" class="_button" :class="$style.footerButton" @click="insertMention"><i class="ti ti-at"></i></button>
|
||||||
<button v-tooltip="i18n.ts.hashtags" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: withHashtags }]" @click="withHashtags = !withHashtags"><i class="ti ti-hash"></i></button>
|
<button v-tooltip="i18n.ts.hashtags" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: withHashtags }]" @click="withHashtags = !withHashtags"><i class="ti ti-hash"></i></button>
|
||||||
|
@ -104,6 +106,7 @@ import MkNoteSimple from '@/components/MkNoteSimple.vue';
|
||||||
import MkNotePreview from '@/components/MkNotePreview.vue';
|
import MkNotePreview from '@/components/MkNotePreview.vue';
|
||||||
import XPostFormAttaches from '@/components/MkPostFormAttaches.vue';
|
import XPostFormAttaches from '@/components/MkPostFormAttaches.vue';
|
||||||
import MkPollEditor from '@/components/MkPollEditor.vue';
|
import MkPollEditor from '@/components/MkPollEditor.vue';
|
||||||
|
import MkEventEditor from '@/components/MkEventEditor.vue';
|
||||||
import { host, url } from '@/config';
|
import { host, url } from '@/config';
|
||||||
import { erase, unique } from '@/scripts/array';
|
import { erase, unique } from '@/scripts/array';
|
||||||
import { extractMentions } from '@/scripts/extract-mentions';
|
import { extractMentions } from '@/scripts/extract-mentions';
|
||||||
|
@ -166,6 +169,12 @@ let poll = $ref<{
|
||||||
expiresAt: string | null;
|
expiresAt: string | null;
|
||||||
expiredAfter: string | null;
|
expiredAfter: string | null;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
let event = $ref<{
|
||||||
|
title: string;
|
||||||
|
start: string;
|
||||||
|
end: string | null;
|
||||||
|
metadata: Record<string, string>;
|
||||||
|
} | null>(null);
|
||||||
let useCw = $ref(false);
|
let useCw = $ref(false);
|
||||||
let showPreview = $ref(defaultStore.state.rememberPostFormToggleStateEnabled ? defaultStore.state.showPostFormPreview : false);
|
let showPreview = $ref(defaultStore.state.rememberPostFormToggleStateEnabled ? defaultStore.state.showPostFormPreview : false);
|
||||||
let cw = $ref<string | null>(null);
|
let cw = $ref<string | null>(null);
|
||||||
|
@ -237,7 +246,7 @@ const maxTextLength = $computed((): number => {
|
||||||
|
|
||||||
const canPost = $computed((): boolean => {
|
const canPost = $computed((): boolean => {
|
||||||
return !posting && !posted &&
|
return !posting && !posted &&
|
||||||
(1 <= textLength || 1 <= files.length || !!poll || !!props.renote) &&
|
(1 <= textLength || 1 <= files.length || !!poll || !!props.renote || !!event) &&
|
||||||
(textLength <= maxTextLength) &&
|
(textLength <= maxTextLength) &&
|
||||||
(!poll || poll.choices.length >= 2);
|
(!poll || poll.choices.length >= 2);
|
||||||
});
|
});
|
||||||
|
@ -343,6 +352,7 @@ function watchForDraft() {
|
||||||
watch($$(cw), () => saveDraft());
|
watch($$(cw), () => saveDraft());
|
||||||
watch($$(disableRightClick), () => saveDraft());
|
watch($$(disableRightClick), () => saveDraft());
|
||||||
watch($$(poll), () => saveDraft());
|
watch($$(poll), () => saveDraft());
|
||||||
|
watch($$(event), () => saveDraft());
|
||||||
watch($$(files), () => saveDraft(), { deep: true });
|
watch($$(files), () => saveDraft(), { deep: true });
|
||||||
watch($$(visibility), () => saveDraft());
|
watch($$(visibility), () => saveDraft());
|
||||||
watch($$(localOnly), () => 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) {
|
function addTag(tag: string) {
|
||||||
insertTextAtCursor(textareaEl, ` #${tag} `);
|
insertTextAtCursor(textareaEl, ` #${tag} `);
|
||||||
}
|
}
|
||||||
|
@ -527,6 +550,7 @@ function clear() {
|
||||||
text = '';
|
text = '';
|
||||||
files = [];
|
files = [];
|
||||||
poll = null;
|
poll = null;
|
||||||
|
event = null;
|
||||||
quoteId = null;
|
quoteId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -654,6 +678,7 @@ function saveDraft() {
|
||||||
localOnly: localOnly,
|
localOnly: localOnly,
|
||||||
files: files,
|
files: files,
|
||||||
poll: poll,
|
poll: poll,
|
||||||
|
event: event,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -715,6 +740,7 @@ async function post(ev?: MouseEvent) {
|
||||||
renoteId: props.renote ? props.renote.id : quoteId ? quoteId : undefined,
|
renoteId: props.renote ? props.renote.id : quoteId ? quoteId : undefined,
|
||||||
channelId: props.channel ? props.channel.id : undefined,
|
channelId: props.channel ? props.channel.id : undefined,
|
||||||
poll: poll,
|
poll: poll,
|
||||||
|
event: event,
|
||||||
cw: useCw ? cw ?? '' : undefined,
|
cw: useCw ? cw ?? '' : undefined,
|
||||||
localOnly: localOnly,
|
localOnly: localOnly,
|
||||||
visibility: visibility,
|
visibility: visibility,
|
||||||
|
@ -878,6 +904,9 @@ onMounted(() => {
|
||||||
if (draft.data.poll) {
|
if (draft.data.poll) {
|
||||||
poll = draft.data.poll;
|
poll = draft.data.poll;
|
||||||
}
|
}
|
||||||
|
if (draft.data.event) {
|
||||||
|
event = draft.data.event;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -896,6 +925,14 @@ onMounted(() => {
|
||||||
expiredAfter: init.poll.expiredAfter,
|
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;
|
visibility = init.visibility;
|
||||||
localOnly = init.localOnly;
|
localOnly = init.localOnly;
|
||||||
quoteId = init.renote ? init.renote.id : null;
|
quoteId = init.renote ? init.renote.id : null;
|
||||||
|
|
113
packages/frontend/src/pages/search.event.vue
Normal file
113
packages/frontend/src/pages/search.event.vue
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
<template>
|
||||||
|
<div class="_gaps">
|
||||||
|
<div class="_gaps">
|
||||||
|
<MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @keydown="onInputKeydown">
|
||||||
|
<template #prefix><i class="ti ti-search"></i></template>
|
||||||
|
</MkInput>
|
||||||
|
<MkRadios v-model="searchOrigin" @update:modelValue="search()">
|
||||||
|
<option value="combined">{{ i18n.ts.all }}</option>
|
||||||
|
<option value="local">{{ i18n.ts.local }}</option>
|
||||||
|
<option value="remote">{{ i18n.ts.remote }}</option>
|
||||||
|
</MkRadios>
|
||||||
|
<MkFolder>
|
||||||
|
<template #label>{{ i18n.ts.options }}</template>
|
||||||
|
|
||||||
|
<MkSelect v-model="eventSort" small>
|
||||||
|
<template #label>{{ i18n.ts.sort }}</template>
|
||||||
|
<option value="startDate">{{ i18n.ts._event.startDate }}</option>
|
||||||
|
<option value="createdAt">{{ i18n.ts.reverseChronological }}</option>
|
||||||
|
</MkSelect>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<MkInput v-model="startDate" small style="margin-top: 10px;" type="date">
|
||||||
|
<template #label>{{ i18n.ts._event.startDate }}</template>
|
||||||
|
</MkInput>
|
||||||
|
<MkInput v-model="endDate" small style="margin-top: 10px;" type="date">
|
||||||
|
<template #label>{{ i18n.ts._event.endDate }}</template>
|
||||||
|
</MkInput>
|
||||||
|
</section>
|
||||||
|
</MkFolder>
|
||||||
|
<div>
|
||||||
|
<MkButton large primary gradate rounded style="margin: 0 auto;" @click="search">{{ i18n.ts.search }}</MkButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MkFoldableSection v-if="eventPagination">
|
||||||
|
<template #header>{{ i18n.ts.searchResult }}</template>
|
||||||
|
<MkNotes :key="key" :pagination="eventPagination" :getDate="eventSort === 'startDate' ? note => note.event.start : undefined"/>
|
||||||
|
</MkFoldableSection>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, defineAsyncComponent, onMounted } from 'vue';
|
||||||
|
import MkNotes from '@/components/MkNotes.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 * as os from '@/os';
|
||||||
|
import MkFoldableSection from '@/components/MkFoldableSection.vue';
|
||||||
|
import { $i } from '@/account';
|
||||||
|
import { instance } from '@/instance';
|
||||||
|
import MkInfo from '@/components/MkInfo.vue';
|
||||||
|
import { useRouter } from '@/router';
|
||||||
|
import MkFolder from '@/components/MkFolder.vue';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
let key = $ref('');
|
||||||
|
let searchQuery = $ref('');
|
||||||
|
let searchOrigin = $ref('combined');
|
||||||
|
let eventSort = $ref('startDate');
|
||||||
|
let eventPagination = $ref();
|
||||||
|
let startDate = $ref(null);
|
||||||
|
let endDate = $ref(null);
|
||||||
|
|
||||||
|
async function search() {
|
||||||
|
const query = searchQuery.toString().trim();
|
||||||
|
|
||||||
|
if (query == null || query === '') return;
|
||||||
|
|
||||||
|
if (query.startsWith('https://')) {
|
||||||
|
const promise = os.api('ap/show', {
|
||||||
|
uri: query,
|
||||||
|
});
|
||||||
|
|
||||||
|
os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject);
|
||||||
|
|
||||||
|
const res = await promise;
|
||||||
|
|
||||||
|
if (res.type === 'User') {
|
||||||
|
router.push(`/@${res.object.username}@${res.object.host}`);
|
||||||
|
} else if (res.type === 'Note') {
|
||||||
|
router.push(`/notes/${res.object.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
eventPagination = {
|
||||||
|
endpoint: 'notes/events/search',
|
||||||
|
limit: 10,
|
||||||
|
offsetMode: true,
|
||||||
|
params: {
|
||||||
|
query: !searchQuery ? undefined : searchQuery,
|
||||||
|
sortBy: eventSort,
|
||||||
|
sinceDate: startDate ? (new Date(startDate)).getTime() : undefined,
|
||||||
|
untilDate: endDate ? (new Date(endDate)).getTime() + 1000 * 3600 * 24 : undefined,
|
||||||
|
origin: searchOrigin,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// only refresh search on query/key change
|
||||||
|
key = JSON.stringify(eventPagination);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInputKeydown(evt: KeyboardEvent) {
|
||||||
|
if (evt.key === 'Enter') {
|
||||||
|
search();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -14,6 +14,10 @@
|
||||||
<MkSpacer v-else-if="tab === 'user'" :contentMax="800">
|
<MkSpacer v-else-if="tab === 'user'" :contentMax="800">
|
||||||
<XUser/>
|
<XUser/>
|
||||||
</MkSpacer>
|
</MkSpacer>
|
||||||
|
|
||||||
|
<MkSpacer v-else-if="tab === 'event'" :contentMax="800">
|
||||||
|
<XEvent/>
|
||||||
|
</MkSpacer>
|
||||||
</MkStickyContainer>
|
</MkStickyContainer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -28,6 +32,7 @@ import MkInfo from '@/components/MkInfo.vue';
|
||||||
|
|
||||||
const XNote = defineAsyncComponent(() => import('./search.note.vue'));
|
const XNote = defineAsyncComponent(() => import('./search.note.vue'));
|
||||||
const XUser = defineAsyncComponent(() => import('./search.user.vue'));
|
const XUser = defineAsyncComponent(() => import('./search.user.vue'));
|
||||||
|
const XEvent = defineAsyncComponent(() => import('./search.event.vue'));
|
||||||
|
|
||||||
let tab = $ref('note');
|
let tab = $ref('note');
|
||||||
|
|
||||||
|
@ -43,6 +48,10 @@ const headerTabs = $computed(() => [{
|
||||||
key: 'user',
|
key: 'user',
|
||||||
title: i18n.ts.users,
|
title: i18n.ts.users,
|
||||||
icon: 'ti ti-users',
|
icon: 'ti ti-users',
|
||||||
|
}, {
|
||||||
|
key: 'event',
|
||||||
|
title: i18n.ts.events,
|
||||||
|
icon: 'ti ti-calendar',
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
definePageMetadata(computed(() => ({
|
definePageMetadata(computed(() => ({
|
||||||
|
|
51
packages/frontend/src/pages/user/events.vue
Normal file
51
packages/frontend/src/pages/user/events.vue
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
<template>
|
||||||
|
<MkSpacer :contentMax="800" style="padding-top: 0">
|
||||||
|
<MkStickyContainer>
|
||||||
|
<template #header>
|
||||||
|
<MkTab v-model="include" :class="$style.tab">
|
||||||
|
<option value="upcoming">{{ i18n.ts._event.startDate }}</option>
|
||||||
|
<option :value="null">{{ i18n.ts.reverseChronological }}</option>
|
||||||
|
</MkTab>
|
||||||
|
</template>
|
||||||
|
<MkNotes :noGap="true" :pagination="pagination" :class="$style.tl" :getDate="include === 'upcoming' ? note => note.event.start : undefined "/>
|
||||||
|
</MkStickyContainer>
|
||||||
|
</MkSpacer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import * as misskey from 'cherrypick-js';
|
||||||
|
import MkNotes from '@/components/MkNotes.vue';
|
||||||
|
import MkTab from '@/components/MkTab.vue';
|
||||||
|
import { i18n } from '@/i18n';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
user: misskey.entities.UserDetailed;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const include = ref<string | null>('upcoming');
|
||||||
|
|
||||||
|
const pagination = {
|
||||||
|
endpoint: 'notes/events/search' as const,
|
||||||
|
limit: 10,
|
||||||
|
offsetMode: include.value === 'upcoming',
|
||||||
|
params: computed(() => ({
|
||||||
|
users: [props.user.id],
|
||||||
|
sortBy: include.value === 'upcoming' ? 'startDate' : 'createdAt',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.tab {
|
||||||
|
margin: calc(var(--margin) / 2) 0;
|
||||||
|
padding: calc(var(--margin) / 2) 0;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl {
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: clip;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -2,9 +2,25 @@
|
||||||
<MkStickyContainer>
|
<MkStickyContainer>
|
||||||
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
|
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
|
||||||
<div>
|
<div>
|
||||||
|
<Transition name="fade" mode="out-in">
|
||||||
<div v-if="user">
|
<div v-if="user">
|
||||||
<XHome v-if="tab === 'home'" :user="user"/>
|
<XHome v-if="tab === 'home'" :user="user"/>
|
||||||
<XTimeline v-else-if="tab === 'notes'" :user="user"/>
|
<XTimeline v-else-if="tab === 'notes'" :user="user"/>
|
||||||
|
<XEvent v-else-if="tab === 'events'" :user="user"/>
|
||||||
|
<XActivity v-else-if="tab === 'activity'" :user="user"/>
|
||||||
|
<XAchievements v-else-if="tab === 'achievements'" :user="user"/>
|
||||||
|
<XReactions v-else-if="tab === 'reactions'" :user="user"/>
|
||||||
|
<XClips v-else-if="tab === 'clips'" :user="user"/>
|
||||||
|
<XPages v-else-if="tab === 'pages'" :user="user"/>
|
||||||
|
<XGallery v-else-if="tab === 'gallery'" :user="user"/>
|
||||||
|
</div>
|
||||||
|
<MkError v-else-if="error" @retry="fetchUser()"/>
|
||||||
|
<MkLoading v-else/>
|
||||||
|
</Transition>
|
||||||
|
<div v-if="user">
|
||||||
|
<XHome v-if="tab === 'home'" :user="user"/>
|
||||||
|
<XTimeline v-else-if="tab === 'notes'" :user="user"/>
|
||||||
|
<XEvent v-else-if="tab === 'events'" :user="user"/>
|
||||||
<XActivity v-else-if="tab === 'activity'" :user="user"/>
|
<XActivity v-else-if="tab === 'activity'" :user="user"/>
|
||||||
<XAchievements v-else-if="tab === 'achievements'" :user="user"/>
|
<XAchievements v-else-if="tab === 'achievements'" :user="user"/>
|
||||||
<XReactions v-else-if="tab === 'reactions'" :user="user"/>
|
<XReactions v-else-if="tab === 'reactions'" :user="user"/>
|
||||||
|
@ -31,6 +47,7 @@ import { $i } from '@/account';
|
||||||
|
|
||||||
const XHome = defineAsyncComponent(() => import('./home.vue'));
|
const XHome = defineAsyncComponent(() => import('./home.vue'));
|
||||||
const XTimeline = defineAsyncComponent(() => import('./index.timeline.vue'));
|
const XTimeline = defineAsyncComponent(() => import('./index.timeline.vue'));
|
||||||
|
const XEvent = defineAsyncComponent(() => import('./events.vue'));
|
||||||
const XActivity = defineAsyncComponent(() => import('./activity.vue'));
|
const XActivity = defineAsyncComponent(() => import('./activity.vue'));
|
||||||
const XAchievements = defineAsyncComponent(() => import('./achievements.vue'));
|
const XAchievements = defineAsyncComponent(() => import('./achievements.vue'));
|
||||||
const XReactions = defineAsyncComponent(() => import('./reactions.vue'));
|
const XReactions = defineAsyncComponent(() => import('./reactions.vue'));
|
||||||
|
@ -74,6 +91,10 @@ const headerTabs = $computed(() => user ? [{
|
||||||
key: 'notes',
|
key: 'notes',
|
||||||
title: i18n.ts.notes,
|
title: i18n.ts.notes,
|
||||||
icon: 'ti ti-pencil',
|
icon: 'ti ti-pencil',
|
||||||
|
}, {
|
||||||
|
key: 'events',
|
||||||
|
title: i18n.ts.events,
|
||||||
|
icon: 'ti ti-calendar',
|
||||||
}, {
|
}, {
|
||||||
key: 'activity',
|
key: 'activity',
|
||||||
title: i18n.ts.activity,
|
title: i18n.ts.activity,
|
||||||
|
|
Loading…
Reference in a new issue