Merge pull request #10628 from misskey-dev/develop

This commit is contained in:
NoriDev 2023-06-18 04:51:25 +09:00
commit c1f6c3ac83
42 changed files with 1497 additions and 20 deletions

View file

@ -118,6 +118,7 @@ Meilisearchの設定に`index`が必要になりました。値はMisskeyサー
- Node.js 18.16.0以上が必要になりました
### General
- Add support for user created events. Includes basic federation of ActivityPub Event objects. [PR 10628](https://github.com/misskey-dev/misskey/pull/10628) @ssmucny
- アカウントの引っ越し(フォロワー引き継ぎ)に対応
- Meilisearchを全文検索に使用できるようになりました
* 「フォロワーのみ」の投稿は検索結果に表示されません。

View file

@ -44,6 +44,7 @@
- 노트 작성 폼에서 본문 미리보기 상태 기억 ([shrimpia/misskey](https://github.com/shrimpia/misskey))
- 리모트에 존재하는 커스텀 이모지도 자신의 서버 내에 같은 이름의 이모지가 있으면 리액션 할 수 있도록 ([shrimpia/misskey@e91295f](https://github.com/shrimpia/misskey/commit/e91295ff9c6f8ac90f61c8de7a891a6836e48e95), [shrimpia/misskey@010378f](https://github.com/shrimpia/misskey/commit/010378fae659ad3015bfade4346209e01bb2a902), [shrimpia/misskey@acf2a30](https://github.com/shrimpia/misskey/commit/acf2a30e8a8c57525dfbab499dbb0b6c7d8e43c2))
- 「이미 본 리노트를 간략화하기」 옵션의 기본값을 꺼짐으로 설정
- 이벤트 기능 (misskey-dev/misskey#10628)
### Client
- (Friendly) 일부 페이지를 제외하고 플로팅 버튼을 표시하지 않음

View file

@ -1065,6 +1065,9 @@ accountMoved: "This user has moved to a new account:"
accountMovedShort: "This account has been migrated."
operationForbidden: "Operation forbidden"
forceShowAds: "Always show ads"
event: "Event"
events: "Events"
reverseChronological: "flashback"
addMemo: "Add memo"
editMemo: "Edit memo"
reactionsList: "Reactions"
@ -1155,6 +1158,34 @@ _initialAccountSetting:
laterAreYouSure: "Really do profile setup later?"
_serverRules:
description: "A set of rules to be displayed before registration. Setting a summary of the Terms of Service is recommended."
_event:
title: "Title"
startDateTime: "Start date time"
endDateTime: "End date time"
startDate: "Start date"
endDate: "End date"
startTime: "Start time"
endTime: "End time"
detailName: "Details"
detailValue: "value"
location: "Location"
url: "URL"
doorTime: "Door Time"
organizer: "Organizer"
organizerLink: "Organizer Link"
audience: "Audience"
language: "Language"
ageRange: "Age Range"
ticketsUrl: "Tickets"
isFree: "Free"
price: "Price"
availability: "Availability"
from: "From"
until: "Until"
availabilityStart: "Availability Start"
availabilityEnd: "Availability End"
keywords: "Keywords"
performers: "Performers"
_accountMigration:
moveFrom: "Migrate another account to this one"
moveFromSub: "Create alias to another account"

32
locales/index.d.ts vendored
View file

@ -1068,6 +1068,9 @@ export interface Locale {
"accountMovedShort": string;
"operationForbidden": string;
"forceShowAds": string;
"event": string;
"events": string;
"reverseChronological": string;
"addMemo": string;
"editMemo": string;
"reactionsList": string;
@ -1164,6 +1167,35 @@ export interface Locale {
"_serverRules": {
"description": string;
};
"_event": {
"title": string;
"startDateTime": string;
"endDateTime": string;
"startDate": string;
"endDate": string;
"startTime": string;
"endTime": string;
"detailName": string;
"detailValue": string;
"location": string;
"url": string;
"doorTime": string;
"organizer": string;
"organizerLink": string;
"audience": string;
"language": string;
"ageRange": string;
"ticketsUrl": string;
"isFree": string;
"price": string;
"availability": string;
"from": string;
"until": string;
"availabilityStart": string;
"availabilityEnd": string;
"keywords": string;
"performers": string;
};
"_accountMigration": {
"moveFrom": string;
"moveFromSub": string;

View file

@ -1065,6 +1065,9 @@ accountMoved: "このユーザーは新しいアカウントに移行しまし
accountMovedShort: "このアカウントは移行されています"
operationForbidden: "この操作はできません"
forceShowAds: "常に広告を表示する"
event: "イベント"
events: "イベント"
reverseChronological: "倒叙"
addMemo: "メモを追加"
editMemo: "メモを編集"
reactionsList: "リアクション一覧"
@ -1162,6 +1165,35 @@ _initialAccountSetting:
_serverRules:
description: "新規登録前に表示する、サーバーの簡潔なルールを設定します。内容は利用規約の要約とすることを推奨します。"
_event:
title: "題名"
startDateTime: "開始日時"
endDateTime: "終了日時"
startDate: "開始日"
endDate: "終了日"
startTime: "開始時刻"
endTime: "終了時刻"
detailName: "属性"
detailValue: "値"
location: "所在地"
url: "URL"
doorTime: "ドアタイム"
organizer: "主催者"
organizerLink: "主催者リンク"
audience: "オーディエンス"
language: "言語"
ageRange: "年齢層"
ticketsUrl: "チケット"
isFree: "無料"
price: "価格"
availability: "可用性"
from: "から"
until: "まで"
availabilityStart: "アベイラビリティ開始"
availabilityEnd: "アベイラビリティ終了"
keywords: "キーワード"
performers: "出演者"
_accountMigration:
moveFrom: "別のアカウントからこのアカウントに移行"
moveFromSub: "別のアカウントへエイリアスを作成"

View file

@ -1066,6 +1066,9 @@ accountMoved: "이 사용자는 다음 계정으로 이사했어요:"
accountMovedShort: "이사한 계정이에요"
operationForbidden: "사용할 수 없어요"
forceShowAds: "광고를 항상 표시"
event: "이벤트"
events: "이벤트"
reverseChronological: "미래순"
addMemo: "메모 추가"
editMemo: "메모 편집"
reactionsList: "리액션 목록"
@ -1155,6 +1158,34 @@ _initialAccountSetting:
laterAreYouSure: "초기 설정을 나중에 진행할까요?"
_serverRules:
description: "회원 가입 이전에 간단하게 표시할 서버 규칙이에요. 이용 약관의 요약으로 구성하는 것을 추천해요."
_event:
title: "제목"
startDateTime: "시작 일시"
endDateTime: "종료 일시"
startDate: "시작일"
endDate: "종료일"
startTime: "시작 시간"
endTime: "종료 시간"
detailName: "속성"
detailValue: "값"
location: "위치"
url: "URL"
doorTime: "출입 가능 시간"
organizer: "주최자"
organizerLink: "주최자 링크"
audience: "대상"
language: "언어"
ageRange: "연령층"
ticketsUrl: "티켓"
isFree: "무료"
price: "가격"
availability: "사용 가능 여부"
from: "부터"
until: "까지"
availabilityStart: "이용 가능 시간"
availabilityEnd: "이용 종료 시간"
keywords: "키워드"
performers: "출연자"
_accountMigration:
moveFrom: "다른 계정에서 이 계정으로 이사"
moveFromSub: "다른 계정에 대한 별칭을 생성"

View 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") `);
}
}

View 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`);
}
}

View 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"`);
}
}

View file

@ -118,6 +118,7 @@ import { ApMentionService } from './activitypub/models/ApMentionService.js';
import { ApNoteService } from './activitypub/models/ApNoteService.js';
import { ApPersonService } from './activitypub/models/ApPersonService.js';
import { ApQuestionService } from './activitypub/models/ApQuestionService.js';
import { ApEventService } from './activitypub/models/ApEventService.js';
import { QueueModule } from './QueueModule.js';
import { QueueService } from './QueueService.js';
import { LoggerService } from './LoggerService.js';
@ -247,6 +248,7 @@ const $ApMentionService: Provider = { provide: 'ApMentionService', useExisting:
const $ApNoteService: Provider = { provide: 'ApNoteService', useExisting: ApNoteService };
const $ApPersonService: Provider = { provide: 'ApPersonService', useExisting: ApPersonService };
const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting: ApQuestionService };
const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEventService };
//#endregion
@Module({
@ -374,6 +376,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ApNoteService,
ApPersonService,
ApQuestionService,
ApEventService,
QueueService,
//#region 文字列ベースでのinjection用(循環参照対応のため)
@ -497,6 +500,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$ApNoteService,
$ApPersonService,
$ApQuestionService,
$ApEventService,
//#endregion
],
exports: [
@ -620,6 +624,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ApNoteService,
ApPersonService,
ApQuestionService,
ApEventService,
QueueService,
//#region 文字列ベースでのinjection用(循環参照対応のため)
@ -742,6 +747,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$ApNoteService,
$ApPersonService,
$ApQuestionService,
$ApEventService,
//#endregion
],
})

View file

@ -9,6 +9,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf
import { extractHashtags } from '@/misc/extract-hashtags.js';
import type { IMentionedRemoteUsers } from '@/models/entities/Note.js';
import { Note } from '@/models/entities/Note.js';
import { Event, IEvent } from '@/models/entities/Event.js';
import type { ChannelFollowingsRepository, ChannelsRepository, InstancesRepository, MutedNotesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
import type { DriveFile } from '@/models/entities/DriveFile.js';
import type { App } from '@/models/entities/App.js';
@ -128,6 +129,7 @@ type Option = {
renote?: Note | null;
files?: DriveFile[] | null;
poll?: IPoll | null;
event?: IEvent | null;
localOnly?: boolean | null;
reactionAcceptance?: Note['reactionAcceptance'];
disableRightClick?: boolean | null;
@ -364,6 +366,7 @@ export class NoteCreateService implements OnApplicationShutdown {
name: data.name,
text: data.text,
hasPoll: data.poll != null,
hasEvent: data.event != null,
cw: data.cw == null ? null : data.cw,
tags: tags.map(tag => normalizeForSearch(tag)),
emojis,
@ -409,11 +412,12 @@ export class NoteCreateService implements OnApplicationShutdown {
// 投稿を作成
try {
if (insert.hasPoll) {
if (insert.hasPoll || insert.hasEvent) {
// Start transaction
await this.db.transaction(async transactionalEntityManager => {
await transactionalEntityManager.insert(Note, insert);
if (insert.hasPoll) {
const poll = new Poll({
noteId: insert.id,
choices: data.poll!.choices,
@ -426,6 +430,22 @@ export class NoteCreateService implements OnApplicationShutdown {
});
await transactionalEntityManager.insert(Poll, poll);
}
if (insert.hasEvent) {
const event = new Event({
noteId: insert.id,
start: data.event!.start,
end: data.event!.end ?? undefined,
title: data.event!.title,
metadata: data.event!.metadata,
noteVisibility: insert.visibility,
userId: user.id,
userHost: user.host,
});
await transactionalEntityManager.insert(Event, event);
}
});
} else {
await this.notesRepository.insert(insert);

View file

@ -20,7 +20,7 @@ import { MfmService } from '@/core/MfmService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import type { UserKeypair } from '@/models/entities/UserKeypair.js';
import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFilesRepository, EmojisRepository, PollsRepository } from '@/models/index.js';
import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFilesRepository, EmojisRepository, PollsRepository, EventsRepository } from '@/models/index.js';
import { bindThis } from '@/decorators.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { isNotNull } from '@/misc/is-not-null.js';
@ -53,6 +53,9 @@ export class ApRendererService {
@Inject(DI.pollsRepository)
private pollsRepository: PollsRepository,
@Inject(DI.eventsRepository)
private eventsRepository: EventsRepository,
private customEmojiService: CustomEmojiService,
private userEntityService: UserEntityService,
private driveFileEntityService: DriveFileEntityService,
@ -429,6 +432,17 @@ export class ApRendererService {
_misskey_talk: true,
} as const : {};
let asEvent = {};
if (note.hasEvent) {
const event = await this.eventsRepository.findOneBy({ noteId: note.id });
asEvent = event ? {
type: 'Event',
name: event.title,
startTime: event.start,
endTime: event.end,
} as const : {};
}
return {
id: `${this.config.url}/notes/${note.id}`,
type: 'Note',
@ -449,6 +463,7 @@ export class ApRendererService {
attachment: files.map(x => this.renderDocument(x)),
sensitive: note.cw != null || files.some(file => file.isSensitive),
tag,
...asEvent,
...asPoll,
...asTalk,
};

View file

@ -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');
}
}
}

View file

@ -31,6 +31,7 @@ import { ApPersonService } from './ApPersonService.js';
import { extractApHashtags } from './tag.js';
import { ApMentionService } from './ApMentionService.js';
import { ApQuestionService } from './ApQuestionService.js';
import { ApEventService } from './ApEventService.js';
import { ApImageService } from './ApImageService.js';
import type { Resolver } from '../ApResolverService.js';
import type { IObject, IPost } from '../type.js';
@ -65,6 +66,7 @@ export class ApNoteService {
private apMentionService: ApMentionService,
private apImageService: ApImageService,
private apQuestionService: ApQuestionService,
private apEventService: ApEventService,
private metaService: MetaService,
private messagingService: MessagingService,
private appLockService: AppLockService,
@ -295,6 +297,7 @@ export class ApNoteService {
const apEmojis = emojis.map(emoji => emoji.name);
const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined);
const event = await this.apEventService.extractEventFromNote(note, resolver).catch(() => undefined);
if (isMessaging) {
for (const recipient of visibleUsers) {
@ -319,6 +322,7 @@ export class ApNoteService {
apHashtags,
apEmojis,
poll,
event,
uri: note.id,
url: url,
}, silent);

View file

@ -134,6 +134,9 @@ export interface IQuestion extends IObject {
export const isQuestion = (object: IObject): object is IQuestion =>
getApType(object) === 'Note' || getApType(object) === 'Question';
export const isEvent = (object: IObject): object is IObject =>
getApType(object) === 'Note' || getApType(object) === 'Event';
interface IQuestionChoice {
name?: string;
replies?: ICollection;

View file

@ -9,7 +9,7 @@ import { awaitAll } from '@/misc/prelude/await-all.js';
import type { User } from '@/models/entities/User.js';
import type { Note } from '@/models/entities/Note.js';
import type { NoteReaction } from '@/models/entities/NoteReaction.js';
import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, DriveFilesRepository } from '@/models/index.js';
import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, DriveFilesRepository, EventsRepository } from '@/models/index.js';
import { bindThis } from '@/decorators.js';
import { isNotNull } from '@/misc/is-not-null.js';
import type { OnModuleInit } from '@nestjs/common';
@ -43,6 +43,9 @@ export class NoteEntityService implements OnModuleInit {
@Inject(DI.pollsRepository)
private pollsRepository: PollsRepository,
@Inject(DI.eventsRepository)
private eventsRepository: EventsRepository,
@Inject(DI.pollVotesRepository)
private pollVotesRepository: PollVotesRepository,
@ -169,6 +172,17 @@ export class NoteEntityService implements OnModuleInit {
};
}
@bindThis
private async populateEvent(note: Note) {
const event = await this.eventsRepository.findOneByOrFail({ noteId: note.id });
return {
title: event.title,
start: event.start,
end: event.end,
metadata: event.metadata,
};
}
@bindThis
private async populateMyReaction(note: Note, meId: User['id'], _hint_?: {
myReactions: Map<Note['id'], NoteReaction | null>;
@ -354,6 +368,7 @@ export class NoteEntityService implements OnModuleInit {
}) : undefined,
poll: note.hasPoll ? this.populatePoll(note, meId) : undefined,
event: note.hasEvent ? this.populateEvent(note) : undefined,
...(meId ? {
myReaction: this.populateMyReaction(note, meId, options?._hint_),

View file

@ -37,6 +37,7 @@ export const DI = {
followRequestsRepository: Symbol('followRequestsRepository'),
instancesRepository: Symbol('instancesRepository'),
emojisRepository: Symbol('emojisRepository'),
eventsRepository: Symbol('eventsRepository'),
driveFilesRepository: Symbol('driveFilesRepository'),
driveFoldersRepository: Symbol('driveFoldersRepository'),
metasRepository: Symbol('metasRepository'),

View file

@ -1,6 +1,6 @@
import { Module } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserGroup, UserGroupJoining, UserGroupInvitation, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, MessagingMessage, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite, UserMemo, UserListFavorite } from './index.js';
import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserGroup, UserGroupJoining, UserGroupInvitation, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, Event, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, MessagingMessage, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite, UserMemo, UserListFavorite } from './index.js';
import type { DataSource } from 'typeorm';
import type { Provider } from '@nestjs/common';
@ -184,6 +184,12 @@ const $emojisRepository: Provider = {
inject: [DI.db],
};
const $eventsRepository: Provider = {
provide: DI.eventsRepository,
useFactory: (db: DataSource) => db.getRepository(Event),
inject: [DI.db],
};
const $driveFilesRepository: Provider = {
provide: DI.driveFilesRepository,
useFactory: (db: DataSource) => db.getRepository(DriveFile),
@ -458,6 +464,7 @@ const $userMemosRepository: Provider = {
$followRequestsRepository,
$instancesRepository,
$emojisRepository,
$eventsRepository,
$driveFilesRepository,
$driveFoldersRepository,
$metasRepository,
@ -530,6 +537,7 @@ const $userMemosRepository: Provider = {
$followRequestsRepository,
$instancesRepository,
$emojisRepository,
$eventsRepository,
$driveFilesRepository,
$driveFoldersRepository,
$metasRepository,

View 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;
}

View file

@ -53,6 +53,11 @@ export class Note {
})
public threadId: string | null;
@Column('boolean', {
default: false,
})
public hasEvent: boolean;
// TODO: varcharにしたい
@Column('text', {
nullable: true,

View file

@ -16,6 +16,7 @@ import { ClipFavorite } from '@/models/entities/ClipFavorite.js';
import { DriveFile } from '@/models/entities/DriveFile.js';
import { DriveFolder } from '@/models/entities/DriveFolder.js';
import { Emoji } from '@/models/entities/Emoji.js';
import { Event } from '@/models/entities/Event.js';
import { Following } from '@/models/entities/Following.js';
import { FollowRequest } from '@/models/entities/FollowRequest.js';
import { GalleryLike } from '@/models/entities/GalleryLike.js';
@ -89,6 +90,7 @@ export {
DriveFile,
DriveFolder,
Emoji,
Event,
Following,
FollowRequest,
GalleryLike,
@ -161,6 +163,7 @@ export type ClipFavoritesRepository = Repository<ClipFavorite>;
export type DriveFilesRepository = Repository<DriveFile>;
export type DriveFoldersRepository = Repository<DriveFolder>;
export type EmojisRepository = Repository<Emoji>;
export type EventsRepository = Repository<Event>;
export type FollowingsRepository = Repository<Following>;
export type FollowRequestsRepository = Repository<FollowRequest>;
export type GalleryLikesRepository = Repository<GalleryLike>;

View file

@ -24,6 +24,7 @@ import { ClipFavorite } from '@/models/entities/ClipFavorite.js';
import { DriveFile } from '@/models/entities/DriveFile.js';
import { DriveFolder } from '@/models/entities/DriveFolder.js';
import { Emoji } from '@/models/entities/Emoji.js';
import { Event } from '@/models/entities/Event.js';
import { Following } from '@/models/entities/Following.js';
import { FollowRequest } from '@/models/entities/FollowRequest.js';
import { GalleryLike } from '@/models/entities/GalleryLike.js';
@ -165,6 +166,7 @@ export const entities = [
Poll,
PollVote,
Emoji,
Event,
Hashtag,
SwSubscription,
AbuseUserReport,

View file

@ -264,6 +264,7 @@ import * as ep___notes_mediaTimeline from './endpoints/notes/media-timeline.js';
import * as ep___notes_mentions from './endpoints/notes/mentions.js';
import * as ep___notes_polls_recommendation from './endpoints/notes/polls/recommendation.js';
import * as ep___notes_polls_vote from './endpoints/notes/polls/vote.js';
import * as ep___notes_events_search from './endpoints/notes/events/search.js';
import * as ep___notes_reactions from './endpoints/notes/reactions.js';
import * as ep___notes_reactions_create from './endpoints/notes/reactions/create.js';
import * as ep___notes_reactions_delete from './endpoints/notes/reactions/delete.js';
@ -625,6 +626,7 @@ const $notes_mediaTimeline: Provider = { provide: 'ep:notes/media-timeline', use
const $notes_mentions: Provider = { provide: 'ep:notes/mentions', useClass: ep___notes_mentions.default };
const $notes_polls_recommendation: Provider = { provide: 'ep:notes/polls/recommendation', useClass: ep___notes_polls_recommendation.default };
const $notes_polls_vote: Provider = { provide: 'ep:notes/polls/vote', useClass: ep___notes_polls_vote.default };
const $notes_events_search: Provider = { provide: 'ep:notes/events/search', useClass: ep___notes_events_search.default };
const $notes_reactions: Provider = { provide: 'ep:notes/reactions', useClass: ep___notes_reactions.default };
const $notes_reactions_create: Provider = { provide: 'ep:notes/reactions/create', useClass: ep___notes_reactions_create.default };
const $notes_reactions_delete: Provider = { provide: 'ep:notes/reactions/delete', useClass: ep___notes_reactions_delete.default };
@ -990,6 +992,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$notes_mentions,
$notes_polls_recommendation,
$notes_polls_vote,
$notes_events_search,
$notes_reactions,
$notes_reactions_create,
$notes_reactions_delete,
@ -1348,6 +1351,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$notes_mentions,
$notes_polls_recommendation,
$notes_polls_vote,
$notes_events_search,
$notes_reactions,
$notes_reactions_create,
$notes_reactions_delete,

View file

@ -264,6 +264,7 @@ import * as ep___notes_mediaTimeline from './endpoints/notes/media-timeline.js';
import * as ep___notes_mentions from './endpoints/notes/mentions.js';
import * as ep___notes_polls_recommendation from './endpoints/notes/polls/recommendation.js';
import * as ep___notes_polls_vote from './endpoints/notes/polls/vote.js';
import * as ep___notes_events_search from './endpoints/notes/events/search.js';
import * as ep___notes_reactions from './endpoints/notes/reactions.js';
import * as ep___notes_reactions_create from './endpoints/notes/reactions/create.js';
import * as ep___notes_reactions_delete from './endpoints/notes/reactions/delete.js';
@ -623,6 +624,7 @@ const eps = [
['notes/mentions', ep___notes_mentions],
['notes/polls/recommendation', ep___notes_polls_recommendation],
['notes/polls/vote', ep___notes_polls_vote],
['notes/events/search', ep___notes_events_search],
['notes/reactions', ep___notes_reactions],
['notes/reactions/create', ep___notes_reactions_create],
['notes/reactions/delete', ep___notes_reactions_delete],

View file

@ -147,6 +147,16 @@ export const paramDef = {
},
required: ['choices'],
},
event: {
type: 'object',
nullable: true,
properties: {
title: { type: 'string', minLength: 1, maxLength: 128, nullable: false },
start: { type: 'integer', nullable: false },
end: { type: 'integer', nullable: true },
metadata: { type: 'object' },
},
},
},
// (re)note with text, files and poll are optional
anyOf: [
@ -282,6 +292,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
text: ps.text ?? undefined,
reply,
renote,
event: ps.event ? {
start: new Date(ps.event.start!),
end: ps.event.end ? new Date(ps.event.end) : null,
title: ps.event.title!,
metadata: ps.event.metadata ?? {},
} : undefined,
cw: ps.cw,
localOnly: ps.localOnly,
reactionAcceptance: ps.reactionAcceptance,

View 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);
});
}
}

View file

@ -1677,6 +1677,12 @@ export type Endpoints = {
expiresAt?: null | number;
expiredAfter?: null | number;
};
event?: null | {
title: string;
start: number;
end?: null | number;
metadata: Record<string, string>;
};
};
res: {
createdNote: Note;
@ -1754,6 +1760,24 @@ export type Endpoints = {
};
res: null;
};
'notes/events/search': {
req: {
query?: string;
sinceId?: Note['id'];
untilId?: Note['id'];
limit?: number;
offset?: number;
users?: User['id'][];
sinceDate?: number;
untilDate?: number;
sortBy?: 'startDate' | 'craetedAt';
filters?: {
key: string;
values: (string | null)[];
}[];
};
res: Note[];
};
'notes/reactions': {
req: {
noteId: Note['id'];
@ -2406,6 +2430,12 @@ type Note = {
replyId: Note['id'];
renote?: Note;
renoteId: Note['id'];
event?: {
title: string;
start: DateString;
end: DateString | null;
metadata: Record<string, string>;
};
files: DriveFile[];
fileIds: DriveFile['id'][];
visibility: 'public' | 'home' | 'followers' | 'specified';
@ -2719,7 +2749,7 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u
//
// src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts
// src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts
// src/api.types.ts:596:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
// src/api.types.ts:614:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
// src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts
// (No @packageDocumentation comment for this package)

View file

@ -488,6 +488,12 @@ export type Endpoints = {
expiresAt?: null | number;
expiredAfter?: null | number;
};
event?: null | {
title: string;
start: number;
end?: null | number;
metadata: Record<string, string>;
}
}; res: { createdNote: Note }; };
'notes/delete': { req: { noteId: Note['id']; }; res: null; };
'notes/favorites/create': { req: { noteId: Note['id']; }; res: null; };
@ -499,6 +505,18 @@ export type Endpoints = {
'notes/mentions': { req: { following?: boolean; limit?: number; sinceId?: Note['id']; untilId?: Note['id']; }; res: Note[]; };
'notes/polls/recommendation': { req: TODO; res: TODO; };
'notes/polls/vote': { req: { noteId: Note['id']; choice: number; }; res: null; };
'notes/events/search': { req: {
query?: string;
sinceId?: Note['id'];
untilId?: Note['id'];
limit?: number;
offset?: number;
users?: User['id'][];
sinceDate?: number;
untilDate?: number;
sortBy?: 'startDate' | 'craetedAt';
filters?: { key: string[], values: (string | null)[] }[];
}; res: Note[]; };
'notes/reactions': { req: { noteId: Note['id']; type?: string | null; limit?: number; }; res: NoteReaction[]; };
'notes/reactions/create': { req: { noteId: Note['id']; reaction: string; }; res: null; };
'notes/reactions/delete': { req: { noteId: Note['id']; }; res: null; };

View file

@ -150,6 +150,12 @@ export type Note = {
replyId: Note['id'];
renote?: Note;
renoteId: Note['id'];
event?: {
title: string,
start: DateString,
end: DateString | null,
metadata: Record<string, string>,
};
files: DriveFile[];
fileIds: DriveFile['id'][];
visibility: 'public' | 'home' | 'followers' | 'specified';

View file

@ -397,6 +397,7 @@ function toStories(component: string): string {
Promise.all([
glob('src/components/global/*.vue'),
glob('src/components/Mk{A,B}*.vue'),
glob('src/components/MkEvent.vue'),
glob('src/components/MkDigitalClock.vue'),
glob('src/components/MkGalleryPostPreview.vue'),
glob('src/components/MkSignupServerRules.vue'),

View file

@ -33,6 +33,11 @@ export default defineComponent({
required: false,
default: false,
},
getDate: {
type: Function, // Note => date string
required: false,
default: undefined,
}
},
setup(props, { slots, expose }) {
@ -58,7 +63,7 @@ export default defineComponent({
if (
i !== props.items.length - 1 &&
new Date(item.createdAt).getDate() !== new Date(props.items[i + 1].createdAt).getDate()
new Date(getDateKey(item)).getDate() !== new Date(getDateKey(props.items[i + 1])).getDate()
) {
const separator = h('div', {
class: $style['separator'],
@ -72,12 +77,12 @@ export default defineComponent({
h('i', {
class: `ti ti-chevron-up ${$style['date-1-icon']}`,
}),
getDateText(item.createdAt),
getDateText(getDateKey(item)),
]),
h('span', {
class: $style['date-2'],
}, [
getDateText(props.items[i + 1].createdAt),
getDateText(getDateKey(props.items[i + 1])),
h('i', {
class: `ti ti-chevron-down ${$style['date-2-icon']}`,
}),
@ -97,6 +102,8 @@ export default defineComponent({
}
});
const getDateKey = (item: MisskeyEntity): string => props.getDate ? props.getDate(item) : item.createdAt;
const renderChildren = () => {
const children = renderChildrenImpl();
if (isDebuggerEnabled(6864)) {

View 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>;

View 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>

View 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>

View file

@ -55,6 +55,7 @@
</div>
</div>
<div style="container-type: inline-size;">
<MkEvent v-if="appearNote.event" :note="appearNote"/>
<p v-if="appearNote.cw != null" :class="$style.cw">
<Mfm v-if="appearNote.cw != ''" :text="appearNote.cw" :author="appearNote.user" :i="$i"/>
<MkCwButton v-model="showContent" :note="appearNote"/>
@ -171,6 +172,7 @@ import MkCwButton from '@/components/MkCwButton.vue';
import MkPoll from '@/components/MkPoll.vue';
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkEvent from '@/components/MkEvent.vue';
import { pleaseLogin } from '@/scripts/please-login';
import { focusPrev, focusNext } from '@/scripts/focus';
import { checkWordMute } from '@/scripts/check-word-mute';

View file

@ -76,6 +76,7 @@
</div>
</header>
<div :class="$style.noteContent">
<MkEvent v-if="appearNote.event" :note="appearNote"/>
<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"/>
<MkCwButton v-model="showContent" :note="appearNote"/>
@ -180,6 +181,7 @@ import MkPoll from '@/components/MkPoll.vue';
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import MkEvent from '@/components/MkEvent.vue';
import { pleaseLogin } from '@/scripts/please-login';
import { checkWordMute } from '@/scripts/check-word-mute';
import { userPage } from '@/filters/user';

View file

@ -13,6 +13,7 @@
ref="notes"
v-slot="{ item: note }"
:items="notes"
:getDate="getDate"
:direction="pagination.reversed ? 'up' : 'down'"
:reversed="pagination.reversed"
:noGap="noGap"
@ -37,6 +38,7 @@ import { infoImageUrl } from '@/instance';
const props = defineProps<{
pagination: Paging;
noGap?: boolean;
getDate?: (any) => string; // custom function to separate notes on something that isn't createdAt
}>();
const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();

View file

@ -49,6 +49,7 @@
<MkNoteSimple v-if="reply" :class="$style.targetNote" :note="reply"/>
<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>
<MkEventEditor v-if="event" v-model="event" @destroyed="event = null"/>
<div v-if="visibility === 'specified'" :class="$style.toSpecified">
<span style="margin-right: 8px;">{{ i18n.ts.recipient }}</span>
<div :class="$style.visibleUsers">
@ -75,6 +76,7 @@
<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.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.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>
@ -104,6 +106,7 @@ import MkNoteSimple from '@/components/MkNoteSimple.vue';
import MkNotePreview from '@/components/MkNotePreview.vue';
import XPostFormAttaches from '@/components/MkPostFormAttaches.vue';
import MkPollEditor from '@/components/MkPollEditor.vue';
import MkEventEditor from '@/components/MkEventEditor.vue';
import { host, url } from '@/config';
import { erase, unique } from '@/scripts/array';
import { extractMentions } from '@/scripts/extract-mentions';
@ -166,6 +169,12 @@ let poll = $ref<{
expiresAt: string | null;
expiredAfter: string | null;
} | null>(null);
let event = $ref<{
title: string;
start: string;
end: string | null;
metadata: Record<string, string>;
} | null>(null);
let useCw = $ref(false);
let showPreview = $ref(defaultStore.state.rememberPostFormToggleStateEnabled ? defaultStore.state.showPostFormPreview : false);
let cw = $ref<string | null>(null);
@ -237,7 +246,7 @@ const maxTextLength = $computed((): number => {
const canPost = $computed((): boolean => {
return !posting && !posted &&
(1 <= textLength || 1 <= files.length || !!poll || !!props.renote) &&
(1 <= textLength || 1 <= files.length || !!poll || !!props.renote || !!event) &&
(textLength <= maxTextLength) &&
(!poll || poll.choices.length >= 2);
});
@ -343,6 +352,7 @@ function watchForDraft() {
watch($$(cw), () => saveDraft());
watch($$(disableRightClick), () => saveDraft());
watch($$(poll), () => saveDraft());
watch($$(event), () => saveDraft());
watch($$(files), () => saveDraft(), { deep: true });
watch($$(visibility), () => saveDraft());
watch($$(localOnly), () => saveDraft());
@ -387,6 +397,19 @@ function togglePoll() {
}
}
function toggleEvent() {
if (event) {
event = null;
} else {
event = {
title: '',
start: (new Date()).toString(),
end: null,
metadata: {},
};
}
}
function addTag(tag: string) {
insertTextAtCursor(textareaEl, ` #${tag} `);
}
@ -527,6 +550,7 @@ function clear() {
text = '';
files = [];
poll = null;
event = null;
quoteId = null;
}
@ -654,6 +678,7 @@ function saveDraft() {
localOnly: localOnly,
files: files,
poll: poll,
event: event,
},
};
@ -715,6 +740,7 @@ async function post(ev?: MouseEvent) {
renoteId: props.renote ? props.renote.id : quoteId ? quoteId : undefined,
channelId: props.channel ? props.channel.id : undefined,
poll: poll,
event: event,
cw: useCw ? cw ?? '' : undefined,
localOnly: localOnly,
visibility: visibility,
@ -878,6 +904,9 @@ onMounted(() => {
if (draft.data.poll) {
poll = draft.data.poll;
}
if (draft.data.event) {
event = draft.data.event;
}
}
}
@ -896,6 +925,14 @@ onMounted(() => {
expiredAfter: init.poll.expiredAfter,
};
}
if (init.event) {
event = {
title: init.event.title,
start: init.event.start,
end: init.event.end,
metadata: init.event.metadata,
};
}
visibility = init.visibility;
localOnly = init.localOnly;
quoteId = init.renote ? init.renote.id : null;

View 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>

View file

@ -14,6 +14,10 @@
<MkSpacer v-else-if="tab === 'user'" :contentMax="800">
<XUser/>
</MkSpacer>
<MkSpacer v-else-if="tab === 'event'" :contentMax="800">
<XEvent/>
</MkSpacer>
</MkStickyContainer>
</template>
@ -28,6 +32,7 @@ import MkInfo from '@/components/MkInfo.vue';
const XNote = defineAsyncComponent(() => import('./search.note.vue'));
const XUser = defineAsyncComponent(() => import('./search.user.vue'));
const XEvent = defineAsyncComponent(() => import('./search.event.vue'));
let tab = $ref('note');
@ -43,6 +48,10 @@ const headerTabs = $computed(() => [{
key: 'user',
title: i18n.ts.users,
icon: 'ti ti-users',
}, {
key: 'event',
title: i18n.ts.events,
icon: 'ti ti-calendar',
}]);
definePageMetadata(computed(() => ({

View 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>

View file

@ -2,9 +2,25 @@
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<div>
<Transition name="fade" mode="out-in">
<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"/>
<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"/>
<XAchievements v-else-if="tab === 'achievements'" :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 XTimeline = defineAsyncComponent(() => import('./index.timeline.vue'));
const XEvent = defineAsyncComponent(() => import('./events.vue'));
const XActivity = defineAsyncComponent(() => import('./activity.vue'));
const XAchievements = defineAsyncComponent(() => import('./achievements.vue'));
const XReactions = defineAsyncComponent(() => import('./reactions.vue'));
@ -74,6 +91,10 @@ const headerTabs = $computed(() => user ? [{
key: 'notes',
title: i18n.ts.notes,
icon: 'ti ti-pencil',
}, {
key: 'events',
title: i18n.ts.events,
icon: 'ti ti-calendar',
}, {
key: 'activity',
title: i18n.ts.activity,