summaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
authorsyuilo <4439005+syuilo@users.noreply.github.com>2025-03-24 21:32:46 +0900
committerGitHub <noreply@github.com>2025-03-24 21:32:46 +0900
commitf1f24e39d2df3135493e2c2087230b428e2d02b7 (patch)
treea5ae0e9d2cf810649b2f4e08ef4d00ce7ea91dc9 /packages
parentfix(frontend): fix broken styles (diff)
downloadmisskey-f1f24e39d2df3135493e2c2087230b428e2d02b7.tar.gz
misskey-f1f24e39d2df3135493e2c2087230b428e2d02b7.tar.bz2
misskey-f1f24e39d2df3135493e2c2087230b428e2d02b7.zip
Feat: Chat (#15686)
* wip * wip * wip * wip * wip * wip * Update types.ts * Create 1742203321812-chat.js * wip * wip * Update room.vue * Update home.vue * Update home.vue * Update ja-JP.yml * Update index.d.ts * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * Update CHANGELOG.md * wip * Update home.vue * clean up * Update misskey-js.api.md * wip * wip * wip * wip * wip * wip * wip * wip * wip * lint fixes * lint * Update UserEntityService.ts * search * wip * 🎨 * wip * Update home.ownedRooms.vue * wip * Update CHANGELOG.md * Update style.scss * wip * improve performance * improve performance * Update timeline.test.ts
Diffstat (limited to 'packages')
-rw-r--r--packages/backend/migration/1742203321812-chat.js46
-rw-r--r--packages/backend/migration/1742608337548-chat-2.js18
-rw-r--r--packages/backend/migration/1742617546147-chat-3.js26
-rw-r--r--packages/backend/migration/1742707840715-chat-4.js26
-rw-r--r--packages/backend/migration/1742721896936-chat-5.js16
-rw-r--r--packages/backend/migration/1742795111958-chat-6.js20
-rw-r--r--packages/backend/src/core/ChatService.ts776
-rw-r--r--packages/backend/src/core/CoreModule.ts18
-rw-r--r--packages/backend/src/core/GlobalEventService.ts38
-rw-r--r--packages/backend/src/core/NoteCreateService.ts27
-rw-r--r--packages/backend/src/core/NoteReadService.ts143
-rw-r--r--packages/backend/src/core/RoleService.ts3
-rw-r--r--packages/backend/src/core/UserFollowingService.ts28
-rw-r--r--packages/backend/src/core/WebhookTestService.ts2
-rw-r--r--packages/backend/src/core/entities/ChatEntityService.ts376
-rw-r--r--packages/backend/src/core/entities/UserEntityService.ts20
-rw-r--r--packages/backend/src/di-symbols.ts6
-rw-r--r--packages/backend/src/misc/json-schema.ts87
-rw-r--r--packages/backend/src/models/ChatApproval.ts39
-rw-r--r--packages/backend/src/models/ChatMessage.ts85
-rw-r--r--packages/backend/src/models/ChatRoom.ts41
-rw-r--r--packages/backend/src/models/ChatRoomInvitation.ts45
-rw-r--r--packages/backend/src/models/ChatRoomMembership.ts45
-rw-r--r--packages/backend/src/models/NoteUnread.ts68
-rw-r--r--packages/backend/src/models/RepositoryModule.ts60
-rw-r--r--packages/backend/src/models/User.ts11
-rw-r--r--packages/backend/src/models/_.ts23
-rw-r--r--packages/backend/src/models/json-schema/chat-message.ts146
-rw-r--r--packages/backend/src/models/json-schema/chat-room-invitation.ts37
-rw-r--r--packages/backend/src/models/json-schema/chat-room-membership.ts37
-rw-r--r--packages/backend/src/models/json-schema/chat-room.ts40
-rw-r--r--packages/backend/src/models/json-schema/role.ts4
-rw-r--r--packages/backend/src/models/json-schema/user.ts9
-rw-r--r--packages/backend/src/postgres.ts21
-rw-r--r--packages/backend/src/server/ServerModule.ts4
-rw-r--r--packages/backend/src/server/api/StreamingApiServerService.ts3
-rw-r--r--packages/backend/src/server/api/endpoint-list.ts23
-rw-r--r--packages/backend/src/server/api/endpoints/antennas/notes.ts7
-rw-r--r--packages/backend/src/server/api/endpoints/chat/history.ts73
-rw-r--r--packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts105
-rw-r--r--packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts122
-rw-r--r--packages/backend/src/server/api/endpoints/chat/messages/delete.ts52
-rw-r--r--packages/backend/src/server/api/endpoints/chat/messages/react.ts49
-rw-r--r--packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts73
-rw-r--r--packages/backend/src/server/api/endpoints/chat/messages/search.ts76
-rw-r--r--packages/backend/src/server/api/endpoints/chat/messages/show.ts63
-rw-r--r--packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts71
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/create.ts62
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/delete.ts52
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts68
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/invitations/ignore.ts48
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts54
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/join.ts48
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/joining.ts58
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/leave.ts48
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/members.ts74
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/mute.ts49
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/owned.ts54
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/show.ts58
-rw-r--r--packages/backend/src/server/api/endpoints/chat/rooms/update.ts65
-rw-r--r--packages/backend/src/server/api/endpoints/i/notifications-grouped.ts13
-rw-r--r--packages/backend/src/server/api/endpoints/i/notifications.ts11
-rw-r--r--packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts45
-rw-r--r--packages/backend/src/server/api/endpoints/i/update.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/notes/mentions.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts4
-rw-r--r--packages/backend/src/server/api/stream/ChannelsService.ts6
-rw-r--r--packages/backend/src/server/api/stream/Connection.ts17
-rw-r--r--packages/backend/src/server/api/stream/channels/chat-room.ts78
-rw-r--r--packages/backend/src/server/api/stream/channels/chat-user.ts78
-rw-r--r--packages/backend/test-federation/test/timeline.test.ts2
-rw-r--r--packages/backend/test/e2e/mute.ts22
-rw-r--r--packages/backend/test/e2e/thread-mute.ts42
-rw-r--r--packages/backend/test/e2e/users.ts6
-rw-r--r--packages/backend/test/unit/entities/UserEntityService.ts2
-rw-r--r--packages/frontend-embed/src/components/EmPagination.vue20
-rw-r--r--packages/frontend-shared/js/const.ts1
-rw-r--r--packages/frontend-shared/js/scroll.ts12
-rw-r--r--packages/frontend/src/boot/main-boot.ts25
-rw-r--r--packages/frontend/src/components/MkButton.vue10
-rw-r--r--packages/frontend/src/components/MkDateSeparatedList.vue22
-rw-r--r--packages/frontend/src/components/MkFukidashi.vue9
-rw-r--r--packages/frontend/src/components/MkMediaList.vue1
-rw-r--r--packages/frontend/src/components/MkMenu.vue66
-rw-r--r--packages/frontend/src/components/MkPagination.vue39
-rw-r--r--packages/frontend/src/components/MkPolkadots.vue40
-rw-r--r--packages/frontend/src/components/MkUrlPreview.vue1
-rw-r--r--packages/frontend/src/local-storage.ts2
-rw-r--r--packages/frontend/src/navbar.ts8
-rw-r--r--packages/frontend/src/pages/admin/roles.editor.vue20
-rw-r--r--packages/frontend/src/pages/admin/roles.vue8
-rw-r--r--packages/frontend/src/pages/chat/XMessage.vue245
-rw-r--r--packages/frontend/src/pages/chat/XRoom.vue41
-rw-r--r--packages/frontend/src/pages/chat/home.home.vue252
-rw-r--r--packages/frontend/src/pages/chat/home.invitations.vue98
-rw-r--r--packages/frontend/src/pages/chat/home.joiningRooms.vue54
-rw-r--r--packages/frontend/src/pages/chat/home.ownedRooms.vue54
-rw-r--r--packages/frontend/src/pages/chat/home.vue60
-rw-r--r--packages/frontend/src/pages/chat/message.vue55
-rw-r--r--packages/frontend/src/pages/chat/room.form.vue333
-rw-r--r--packages/frontend/src/pages/chat/room.info.vue87
-rw-r--r--packages/frontend/src/pages/chat/room.members.vue73
-rw-r--r--packages/frontend/src/pages/chat/room.search.vue68
-rw-r--r--packages/frontend/src/pages/chat/room.vue426
-rw-r--r--packages/frontend/src/pages/settings/notifications.vue5
-rw-r--r--packages/frontend/src/pages/settings/privacy.vue16
-rw-r--r--packages/frontend/src/pages/settings/sounds.vue1
-rw-r--r--packages/frontend/src/preferences/def.ts7
-rw-r--r--packages/frontend/src/router.definition.ts16
-rw-r--r--packages/frontend/src/style.scss8
-rw-r--r--packages/frontend/src/types/menu.ts16
-rw-r--r--packages/frontend/src/ui/_common_/navbar.vue8
-rw-r--r--packages/frontend/src/utility/autogen/settings-search-index.ts13
-rw-r--r--packages/frontend/src/utility/get-user-menu.ts73
-rw-r--r--packages/frontend/src/utility/sound.ts1
-rw-r--r--packages/frontend/src/utility/upload.ts2
-rw-r--r--packages/misskey-js/README.md10
-rw-r--r--packages/misskey-js/etc/misskey-js.api.md210
-rw-r--r--packages/misskey-js/src/autogen/apiClientJSDoc.ts253
-rw-r--r--packages/misskey-js/src/autogen/endpoint.ts67
-rw-r--r--packages/misskey-js/src/autogen/entities.ts44
-rw-r--r--packages/misskey-js/src/autogen/models.ts5
-rw-r--r--packages/misskey-js/src/autogen/types.ts1581
-rw-r--r--packages/misskey-js/src/consts.ts6
-rw-r--r--packages/misskey-js/src/streaming.types.ts7
-rw-r--r--packages/misskey-js/test/streaming.ts38
126 files changed, 7942 insertions, 753 deletions
diff --git a/packages/backend/migration/1742203321812-chat.js b/packages/backend/migration/1742203321812-chat.js
new file mode 100644
index 0000000000..3d8f7276b5
--- /dev/null
+++ b/packages/backend/migration/1742203321812-chat.js
@@ -0,0 +1,46 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class Chat1742203321812 {
+ name = 'Chat1742203321812'
+
+ async up(queryRunner) {
+ await queryRunner.query(`CREATE TABLE "chat_room" ("id" character varying(32) NOT NULL, "name" character varying(256) NOT NULL, "ownerId" character varying(32) NOT NULL, CONSTRAINT "PK_8aa3a52cf74c96469f0ef9fbe3e" PRIMARY KEY ("id"))`);
+ await queryRunner.query(`CREATE INDEX "IDX_f0d8ad64243fa2ca2800da0dfd" ON "chat_room" ("ownerId") `);
+ await queryRunner.query(`CREATE TABLE "chat_message" ("id" character varying(32) NOT NULL, "fromUserId" character varying(32) NOT NULL, "toUserId" character varying(32), "toRoomId" character varying(32), "text" character varying(4096), "uri" character varying(512), "reads" character varying(32) array NOT NULL DEFAULT '{}', "fileId" character varying(32), "reactions" character varying(1024) array NOT NULL DEFAULT '{}', CONSTRAINT "PK_3cc0d85193aade457d3077dd06b" PRIMARY KEY ("id"))`);
+ await queryRunner.query(`CREATE INDEX "IDX_79a26e7a4d9afa5e4fc05f134e" ON "chat_message" ("fromUserId") `);
+ await queryRunner.query(`CREATE INDEX "IDX_25e097b51d7622c249452c6f75" ON "chat_message" ("toUserId") `);
+ await queryRunner.query(`CREATE INDEX "IDX_f006b8a76efd1abf9f221c175c" ON "chat_message" ("toRoomId") `);
+ await queryRunner.query(`CREATE TABLE "chat_room_membership" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "roomId" character varying(32) NOT NULL, CONSTRAINT "PK_2bd59c741e571b283c048beb69a" PRIMARY KEY ("id"))`);
+ await queryRunner.query(`CREATE INDEX "IDX_d99c5279460fb77ef58c596ce5" ON "chat_room_membership" ("userId") `);
+ await queryRunner.query(`CREATE INDEX "IDX_c25143ebab714e930aeca1c0e8" ON "chat_room_membership" ("roomId") `);
+ await queryRunner.query(`ALTER TABLE "chat_room" ADD CONSTRAINT "FK_f0d8ad64243fa2ca2800da0dfd6" FOREIGN KEY ("ownerId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+ await queryRunner.query(`ALTER TABLE "chat_message" ADD CONSTRAINT "FK_79a26e7a4d9afa5e4fc05f134ed" FOREIGN KEY ("fromUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+ await queryRunner.query(`ALTER TABLE "chat_message" ADD CONSTRAINT "FK_25e097b51d7622c249452c6f757" FOREIGN KEY ("toUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+ await queryRunner.query(`ALTER TABLE "chat_message" ADD CONSTRAINT "FK_f006b8a76efd1abf9f221c175ce" FOREIGN KEY ("toRoomId") REFERENCES "chat_room"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+ await queryRunner.query(`ALTER TABLE "chat_message" ADD CONSTRAINT "FK_fd0f9a4879430239715ad4f8e2a" FOREIGN KEY ("fileId") REFERENCES "drive_file"("id") ON DELETE SET NULL ON UPDATE NO ACTION`);
+ await queryRunner.query(`ALTER TABLE "chat_room_membership" ADD CONSTRAINT "FK_d99c5279460fb77ef58c596ce51" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+ await queryRunner.query(`ALTER TABLE "chat_room_membership" ADD CONSTRAINT "FK_c25143ebab714e930aeca1c0e8d" FOREIGN KEY ("roomId") REFERENCES "chat_room"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "chat_room_membership" DROP CONSTRAINT "FK_c25143ebab714e930aeca1c0e8d"`);
+ await queryRunner.query(`ALTER TABLE "chat_room_membership" DROP CONSTRAINT "FK_d99c5279460fb77ef58c596ce51"`);
+ await queryRunner.query(`ALTER TABLE "chat_message" DROP CONSTRAINT "FK_fd0f9a4879430239715ad4f8e2a"`);
+ await queryRunner.query(`ALTER TABLE "chat_message" DROP CONSTRAINT "FK_f006b8a76efd1abf9f221c175ce"`);
+ await queryRunner.query(`ALTER TABLE "chat_message" DROP CONSTRAINT "FK_25e097b51d7622c249452c6f757"`);
+ await queryRunner.query(`ALTER TABLE "chat_message" DROP CONSTRAINT "FK_79a26e7a4d9afa5e4fc05f134ed"`);
+ await queryRunner.query(`ALTER TABLE "chat_room" DROP CONSTRAINT "FK_f0d8ad64243fa2ca2800da0dfd6"`);
+ await queryRunner.query(`DROP INDEX "public"."IDX_c25143ebab714e930aeca1c0e8"`);
+ await queryRunner.query(`DROP INDEX "public"."IDX_d99c5279460fb77ef58c596ce5"`);
+ await queryRunner.query(`DROP TABLE "chat_room_membership"`);
+ await queryRunner.query(`DROP INDEX "public"."IDX_f006b8a76efd1abf9f221c175c"`);
+ await queryRunner.query(`DROP INDEX "public"."IDX_25e097b51d7622c249452c6f75"`);
+ await queryRunner.query(`DROP INDEX "public"."IDX_79a26e7a4d9afa5e4fc05f134e"`);
+ await queryRunner.query(`DROP TABLE "chat_message"`);
+ await queryRunner.query(`DROP INDEX "public"."IDX_f0d8ad64243fa2ca2800da0dfd"`);
+ await queryRunner.query(`DROP TABLE "chat_room"`);
+ }
+}
diff --git a/packages/backend/migration/1742608337548-chat-2.js b/packages/backend/migration/1742608337548-chat-2.js
new file mode 100644
index 0000000000..9f74a263d6
--- /dev/null
+++ b/packages/backend/migration/1742608337548-chat-2.js
@@ -0,0 +1,18 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class Chat21742608337548 {
+ name = 'Chat21742608337548'
+
+ async up(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "user" ADD "chatScope" character varying(128) NOT NULL DEFAULT 'mutual'`);
+ await queryRunner.query(`CREATE UNIQUE INDEX "IDX_185b6b5afa707b5d36d1ce3144" ON "chat_room_membership" ("userId", "roomId") `);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`DROP INDEX "public"."IDX_185b6b5afa707b5d36d1ce3144"`);
+ await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "chatScope"`);
+ }
+}
diff --git a/packages/backend/migration/1742617546147-chat-3.js b/packages/backend/migration/1742617546147-chat-3.js
new file mode 100644
index 0000000000..116b9a738b
--- /dev/null
+++ b/packages/backend/migration/1742617546147-chat-3.js
@@ -0,0 +1,26 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class Chat31742617546147 {
+ name = 'Chat31742617546147'
+
+ async up(queryRunner) {
+ await queryRunner.query(`CREATE TABLE "chat_approval" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "otherId" character varying(32) NOT NULL, CONSTRAINT "PK_fbbb95d60acf5c85388345b5f5d" PRIMARY KEY ("id"))`);
+ await queryRunner.query(`CREATE INDEX "IDX_530257863e1381a7f2f1d3282f" ON "chat_approval" ("userId") `);
+ await queryRunner.query(`CREATE INDEX "IDX_b1d46037f23d170da5c05fdf75" ON "chat_approval" ("otherId") `);
+ await queryRunner.query(`CREATE UNIQUE INDEX "IDX_12c4768a2f706fc267f2078903" ON "chat_approval" ("userId", "otherId") `);
+ await queryRunner.query(`ALTER TABLE "chat_approval" ADD CONSTRAINT "FK_530257863e1381a7f2f1d3282fe" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+ await queryRunner.query(`ALTER TABLE "chat_approval" ADD CONSTRAINT "FK_b1d46037f23d170da5c05fdf755" FOREIGN KEY ("otherId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "chat_approval" DROP CONSTRAINT "FK_b1d46037f23d170da5c05fdf755"`);
+ await queryRunner.query(`ALTER TABLE "chat_approval" DROP CONSTRAINT "FK_530257863e1381a7f2f1d3282fe"`);
+ await queryRunner.query(`DROP INDEX "public"."IDX_12c4768a2f706fc267f2078903"`);
+ await queryRunner.query(`DROP INDEX "public"."IDX_b1d46037f23d170da5c05fdf75"`);
+ await queryRunner.query(`DROP INDEX "public"."IDX_530257863e1381a7f2f1d3282f"`);
+ await queryRunner.query(`DROP TABLE "chat_approval"`);
+ }
+}
diff --git a/packages/backend/migration/1742707840715-chat-4.js b/packages/backend/migration/1742707840715-chat-4.js
new file mode 100644
index 0000000000..953a53d880
--- /dev/null
+++ b/packages/backend/migration/1742707840715-chat-4.js
@@ -0,0 +1,26 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class Chat41742707840715 {
+ name = 'Chat41742707840715'
+
+ async up(queryRunner) {
+ await queryRunner.query(`CREATE TABLE "chat_room_invitation" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "roomId" character varying(32) NOT NULL, CONSTRAINT "PK_9d489521a312dd28225672de2dc" PRIMARY KEY ("id"))`);
+ await queryRunner.query(`CREATE INDEX "IDX_8552bb38e7ed038c5bdd398a38" ON "chat_room_invitation" ("userId") `);
+ await queryRunner.query(`CREATE INDEX "IDX_5f265075b215fc390a57523b12" ON "chat_room_invitation" ("roomId") `);
+ await queryRunner.query(`CREATE UNIQUE INDEX "IDX_044f2a7962b8ee5bbfaa02e8a3" ON "chat_room_invitation" ("userId", "roomId") `);
+ await queryRunner.query(`ALTER TABLE "chat_room_invitation" ADD CONSTRAINT "FK_8552bb38e7ed038c5bdd398a384" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+ await queryRunner.query(`ALTER TABLE "chat_room_invitation" ADD CONSTRAINT "FK_5f265075b215fc390a57523b12a" FOREIGN KEY ("roomId") REFERENCES "chat_room"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "chat_room_invitation" DROP CONSTRAINT "FK_5f265075b215fc390a57523b12a"`);
+ await queryRunner.query(`ALTER TABLE "chat_room_invitation" DROP CONSTRAINT "FK_8552bb38e7ed038c5bdd398a384"`);
+ await queryRunner.query(`DROP INDEX "public"."IDX_044f2a7962b8ee5bbfaa02e8a3"`);
+ await queryRunner.query(`DROP INDEX "public"."IDX_5f265075b215fc390a57523b12"`);
+ await queryRunner.query(`DROP INDEX "public"."IDX_8552bb38e7ed038c5bdd398a38"`);
+ await queryRunner.query(`DROP TABLE "chat_room_invitation"`);
+ }
+}
diff --git a/packages/backend/migration/1742721896936-chat-5.js b/packages/backend/migration/1742721896936-chat-5.js
new file mode 100644
index 0000000000..00db787cb7
--- /dev/null
+++ b/packages/backend/migration/1742721896936-chat-5.js
@@ -0,0 +1,16 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class Chat51742721896936 {
+ name = 'Chat51742721896936'
+
+ async up(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "chat_room_invitation" ADD "ignored" boolean NOT NULL DEFAULT false`);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "chat_room_invitation" DROP COLUMN "ignored"`);
+ }
+}
diff --git a/packages/backend/migration/1742795111958-chat-6.js b/packages/backend/migration/1742795111958-chat-6.js
new file mode 100644
index 0000000000..9a5dc3e32f
--- /dev/null
+++ b/packages/backend/migration/1742795111958-chat-6.js
@@ -0,0 +1,20 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class Chat61742795111958 {
+ name = 'Chat61742795111958'
+
+ async up(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "chat_room" ADD "description" character varying(2048) NOT NULL DEFAULT ''`);
+ await queryRunner.query(`ALTER TABLE "chat_room" ADD "isArchived" boolean NOT NULL DEFAULT false`);
+ await queryRunner.query(`ALTER TABLE "chat_room_membership" ADD "isMuted" boolean NOT NULL DEFAULT false`);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "chat_room_membership" DROP COLUMN "isMuted"`);
+ await queryRunner.query(`ALTER TABLE "chat_room" DROP COLUMN "isArchived"`);
+ await queryRunner.query(`ALTER TABLE "chat_room" DROP COLUMN "description"`);
+ }
+}
diff --git a/packages/backend/src/core/ChatService.ts b/packages/backend/src/core/ChatService.ts
new file mode 100644
index 0000000000..eece5d0da3
--- /dev/null
+++ b/packages/backend/src/core/ChatService.ts
@@ -0,0 +1,776 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import * as Redis from 'ioredis';
+import { Brackets } from 'typeorm';
+import { DI } from '@/di-symbols.js';
+import type { Config } from '@/config.js';
+import { QueueService } from '@/core/QueueService.js';
+import { IdService } from '@/core/IdService.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
+import { UserEntityService } from '@/core/entities/UserEntityService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
+import { PushNotificationService } from '@/core/PushNotificationService.js';
+import { bindThis } from '@/decorators.js';
+import type { ChatApprovalsRepository, ChatMessagesRepository, ChatRoomInvitationsRepository, ChatRoomMembershipsRepository, ChatRoomsRepository, MiChatMessage, MiChatRoom, MiChatRoomMembership, MiDriveFile, MiUser, MutingsRepository, UsersRepository } from '@/models/_.js';
+import { UserBlockingService } from '@/core/UserBlockingService.js';
+import { QueryService } from '@/core/QueryService.js';
+import { RoleService } from '@/core/RoleService.js';
+import { UserFollowingService } from '@/core/UserFollowingService.js';
+import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js';
+import { Packed } from '@/misc/json-schema.js';
+import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
+import { CustomEmojiService } from '@/core/CustomEmojiService.js';
+import { emojiRegex } from '@/misc/emoji-regex.js';
+
+const MAX_ROOM_MEMBERS = 30;
+const MAX_REACTIONS_PER_MESSAGE = 100;
+const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/;
+
+@Injectable()
+export class ChatService {
+ constructor(
+ @Inject(DI.config)
+ private config: Config,
+
+ @Inject(DI.redis)
+ private redisClient: Redis.Redis,
+
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
+ @Inject(DI.chatMessagesRepository)
+ private chatMessagesRepository: ChatMessagesRepository,
+
+ @Inject(DI.chatApprovalsRepository)
+ private chatApprovalsRepository: ChatApprovalsRepository,
+
+ @Inject(DI.chatRoomsRepository)
+ private chatRoomsRepository: ChatRoomsRepository,
+
+ @Inject(DI.chatRoomInvitationsRepository)
+ private chatRoomInvitationsRepository: ChatRoomInvitationsRepository,
+
+ @Inject(DI.chatRoomMembershipsRepository)
+ private chatRoomMembershipsRepository: ChatRoomMembershipsRepository,
+
+ @Inject(DI.mutingsRepository)
+ private mutingsRepository: MutingsRepository,
+
+ private userEntityService: UserEntityService,
+ private chatEntityService: ChatEntityService,
+ private idService: IdService,
+ private globalEventService: GlobalEventService,
+ private apRendererService: ApRendererService,
+ private queueService: QueueService,
+ private pushNotificationService: PushNotificationService,
+ private userBlockingService: UserBlockingService,
+ private queryService: QueryService,
+ private roleService: RoleService,
+ private userFollowingService: UserFollowingService,
+ private customEmojiService: CustomEmojiService,
+ ) {
+ }
+
+ @bindThis
+ public async createMessageToUser(fromUser: { id: MiUser['id']; host: MiUser['host']; }, toUser: MiUser, params: {
+ text?: string | null;
+ file?: MiDriveFile | null;
+ uri?: string | null;
+ }): Promise<Packed<'ChatMessageLite'>> {
+ if (fromUser.id === toUser.id) {
+ throw new Error('yourself');
+ }
+
+ const approvals = await this.chatApprovalsRepository.createQueryBuilder('approval')
+ .where(new Brackets(qb => { // 自分が相手を許可しているか
+ qb.where('approval.userId = :fromUserId', { fromUserId: fromUser.id })
+ .andWhere('approval.otherId = :toUserId', { toUserId: toUser.id });
+ }))
+ .orWhere(new Brackets(qb => { // 相手が自分を許可しているか
+ qb.where('approval.userId = :toUserId', { toUserId: toUser.id })
+ .andWhere('approval.otherId = :fromUserId', { fromUserId: fromUser.id });
+ }))
+ .take(2)
+ .getMany();
+
+ const otherApprovedMe = approvals.some(approval => approval.userId === toUser.id);
+ const iApprovedOther = approvals.some(approval => approval.userId === fromUser.id);
+
+ if (!otherApprovedMe) {
+ if (toUser.chatScope === 'none') {
+ throw new Error('recipient is cannot chat (none)');
+ } else if (toUser.chatScope === 'followers') {
+ const isFollower = await this.userFollowingService.isFollowing(fromUser.id, toUser.id);
+ if (!isFollower) {
+ throw new Error('recipient is cannot chat (followers)');
+ }
+ } else if (toUser.chatScope === 'following') {
+ const isFollowing = await this.userFollowingService.isFollowing(toUser.id, fromUser.id);
+ if (!isFollowing) {
+ throw new Error('recipient is cannot chat (following)');
+ }
+ } else if (toUser.chatScope === 'mutual') {
+ const isMutual = await this.userFollowingService.isMutual(fromUser.id, toUser.id);
+ if (!isMutual) {
+ throw new Error('recipient is cannot chat (mutual)');
+ }
+ }
+ }
+
+ if (!(await this.roleService.getUserPolicies(toUser.id)).canChat) {
+ throw new Error('recipient is cannot chat (policy)');
+ }
+
+ const blocked = await this.userBlockingService.checkBlocked(toUser.id, fromUser.id);
+ if (blocked) {
+ throw new Error('blocked');
+ }
+
+ const message = {
+ id: this.idService.gen(),
+ fromUserId: fromUser.id,
+ toUserId: toUser.id,
+ text: params.text ? params.text.trim() : null,
+ fileId: params.file ? params.file.id : null,
+ reads: [],
+ uri: params.uri ?? null,
+ } satisfies Partial<MiChatMessage>;
+
+ const inserted = await this.chatMessagesRepository.insertOne(message);
+
+ // 相手を許可しておく
+ if (!iApprovedOther) {
+ this.chatApprovalsRepository.insertOne({
+ id: this.idService.gen(),
+ userId: fromUser.id,
+ otherId: toUser.id,
+ });
+ }
+
+ const packedMessage = await this.chatEntityService.packMessageLiteFor1on1(inserted);
+
+ if (this.userEntityService.isLocalUser(toUser)) {
+ const redisPipeline = this.redisClient.pipeline();
+ redisPipeline.set(`newUserChatMessageExists:${toUser.id}:${fromUser.id}`, message.id);
+ redisPipeline.sadd(`newChatMessagesExists:${toUser.id}`, `user:${fromUser.id}`);
+ redisPipeline.exec();
+ }
+
+ if (this.userEntityService.isLocalUser(fromUser)) {
+ // 自分のストリーム
+ this.globalEventService.publishChatUserStream(fromUser.id, toUser.id, 'message', packedMessage);
+ }
+
+ if (this.userEntityService.isLocalUser(toUser)) {
+ // 相手のストリーム
+ this.globalEventService.publishChatUserStream(toUser.id, fromUser.id, 'message', packedMessage);
+ }
+
+ // 3秒経っても既読にならなかったらイベント発行
+ if (this.userEntityService.isLocalUser(toUser)) {
+ setTimeout(async () => {
+ const marker = await this.redisClient.get(`newUserChatMessageExists:${toUser.id}:${fromUser.id}`);
+
+ if (marker == null) return; // 既読
+
+ const packedMessageForTo = await this.chatEntityService.packMessageDetailed(inserted, toUser);
+ this.globalEventService.publishMainStream(toUser.id, 'newChatMessage', packedMessageForTo);
+ //this.pushNotificationService.pushNotification(toUser.id, 'newChatMessage', packedMessageForTo);
+ }, 3000);
+ }
+
+ return packedMessage;
+ }
+
+ @bindThis
+ public async createMessageToRoom(fromUser: { id: MiUser['id']; host: MiUser['host']; }, toRoom: MiChatRoom, params: {
+ text?: string | null;
+ file?: MiDriveFile | null;
+ uri?: string | null;
+ }): Promise<Packed<'ChatMessageLite'>> {
+ const memberships = await this.chatRoomMembershipsRepository.findBy({ roomId: toRoom.id });
+
+ if (toRoom.ownerId !== fromUser.id && !memberships.some(member => member.userId === fromUser.id)) {
+ throw new Error('you are not a member of the room');
+ }
+
+ const message = {
+ id: this.idService.gen(),
+ fromUserId: fromUser.id,
+ toRoomId: toRoom.id,
+ text: params.text ? params.text.trim() : null,
+ fileId: params.file ? params.file.id : null,
+ reads: [],
+ uri: params.uri ?? null,
+ } satisfies Partial<MiChatMessage>;
+
+ const inserted = await this.chatMessagesRepository.insertOne(message);
+
+ const packedMessage = await this.chatEntityService.packMessageLiteForRoom(inserted);
+
+ this.globalEventService.publishChatRoomStream(toRoom.id, 'message', packedMessage);
+
+ const redisPipeline = this.redisClient.pipeline();
+ for (const membership of memberships) {
+ if (membership.isMuted) continue;
+
+ redisPipeline.set(`newRoomChatMessageExists:${membership.userId}:${toRoom.id}`, message.id);
+ redisPipeline.sadd(`newChatMessagesExists:${membership.userId}`, `room:${toRoom.id}`);
+ }
+ redisPipeline.exec();
+
+ // 3秒経っても既読にならなかったらイベント発行
+ setTimeout(async () => {
+ const redisPipeline = this.redisClient.pipeline();
+ for (const membership of memberships) {
+ redisPipeline.get(`newRoomChatMessageExists:${membership.userId}:${toRoom.id}`);
+ }
+ const markers = await redisPipeline.exec();
+ if (markers == null) throw new Error('redis error');
+
+ if (markers.every(marker => marker[1] == null)) return;
+
+ const packedMessageForTo = await this.chatEntityService.packMessageDetailed(inserted);
+
+ for (let i = 0; i < memberships.length; i++) {
+ const marker = markers[i][1];
+ if (marker == null) continue;
+
+ this.globalEventService.publishMainStream(memberships[i].userId, 'newChatMessage', packedMessageForTo);
+ //this.pushNotificationService.pushNotification(memberships[i].userId, 'newChatMessage', packedMessageForTo);
+ }
+ }, 3000);
+
+ return packedMessage;
+ }
+
+ @bindThis
+ public async readUserChatMessage(
+ readerId: MiUser['id'],
+ senderId: MiUser['id'],
+ ): Promise<void> {
+ const redisPipeline = this.redisClient.pipeline();
+ redisPipeline.del(`newUserChatMessageExists:${readerId}:${senderId}`);
+ redisPipeline.srem(`newChatMessagesExists:${readerId}`, `user:${senderId}`);
+ await redisPipeline.exec();
+ }
+
+ @bindThis
+ public async readRoomChatMessage(
+ readerId: MiUser['id'],
+ roomId: MiChatRoom['id'],
+ ): Promise<void> {
+ const redisPipeline = this.redisClient.pipeline();
+ redisPipeline.del(`newRoomChatMessageExists:${readerId}:${roomId}`);
+ redisPipeline.srem(`newChatMessagesExists:${readerId}`, `room:${roomId}`);
+ await redisPipeline.exec();
+ }
+
+ @bindThis
+ public findMessageById(messageId: MiChatMessage['id']) {
+ return this.chatMessagesRepository.findOneBy({ id: messageId });
+ }
+
+ @bindThis
+ public findMyMessageById(userId: MiUser['id'], messageId: MiChatMessage['id']) {
+ return this.chatMessagesRepository.findOneBy({ id: messageId, fromUserId: userId });
+ }
+
+ @bindThis
+ public async deleteMessage(message: MiChatMessage) {
+ await this.chatMessagesRepository.delete(message.id);
+
+ if (message.toUserId) {
+ const [fromUser, toUser] = await Promise.all([
+ this.usersRepository.findOneByOrFail({ id: message.fromUserId }),
+ this.usersRepository.findOneByOrFail({ id: message.toUserId }),
+ ]);
+
+ if (this.userEntityService.isLocalUser(fromUser)) this.globalEventService.publishChatUserStream(message.fromUserId, message.toUserId, 'deleted', message.id);
+ if (this.userEntityService.isLocalUser(toUser)) this.globalEventService.publishChatUserStream(message.toUserId, message.fromUserId, 'deleted', message.id);
+
+ if (this.userEntityService.isLocalUser(fromUser) && this.userEntityService.isRemoteUser(toUser)) {
+ //const activity = this.apRendererService.addContext(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${message.id}`), fromUser));
+ //this.queueService.deliver(fromUser, activity, toUser.inbox);
+ }
+ } else if (message.toRoomId) {
+ this.globalEventService.publishChatRoomStream(message.toRoomId, 'deleted', message.id);
+ }
+ }
+
+ @bindThis
+ public async userTimeline(meId: MiUser['id'], otherId: MiUser['id'], limit: number, sinceId?: MiChatMessage['id'] | null, untilId?: MiChatMessage['id'] | null) {
+ const query = this.queryService.makePaginationQuery(this.chatMessagesRepository.createQueryBuilder('message'), sinceId, untilId)
+ .andWhere(new Brackets(qb => {
+ qb
+ .where(new Brackets(qb => {
+ qb
+ .where('message.fromUserId = :meId')
+ .andWhere('message.toUserId = :otherId');
+ }))
+ .orWhere(new Brackets(qb => {
+ qb
+ .where('message.fromUserId = :otherId')
+ .andWhere('message.toUserId = :meId');
+ }));
+ }))
+ .setParameter('meId', meId)
+ .setParameter('otherId', otherId);
+
+ const messages = await query.take(limit).getMany();
+
+ return messages;
+ }
+
+ @bindThis
+ public async roomTimeline(roomId: MiChatRoom['id'], limit: number, sinceId?: MiChatMessage['id'] | null, untilId?: MiChatMessage['id'] | null) {
+ const query = this.queryService.makePaginationQuery(this.chatMessagesRepository.createQueryBuilder('message'), sinceId, untilId)
+ .where('message.toRoomId = :roomId', { roomId })
+ .leftJoinAndSelect('message.file', 'file')
+ .leftJoinAndSelect('message.fromUser', 'fromUser');
+
+ const messages = await query.take(limit).getMany();
+
+ return messages;
+ }
+
+ @bindThis
+ public async userHistory(meId: MiUser['id'], limit: number): Promise<MiChatMessage[]> {
+ const history: MiChatMessage[] = [];
+
+ const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
+ .select('muting.muteeId')
+ .where('muting.muterId = :muterId', { muterId: meId });
+
+ for (let i = 0; i < limit; i++) {
+ const found = history.map(m => (m.fromUserId === meId) ? m.toUserId! : m.fromUserId!);
+
+ const query = this.chatMessagesRepository.createQueryBuilder('message')
+ .orderBy('message.id', 'DESC')
+ .where(new Brackets(qb => {
+ qb
+ .where('message.fromUserId = :meId', { meId: meId })
+ .orWhere('message.toUserId = :meId', { meId: meId });
+ }))
+ .andWhere('message.toRoomId IS NULL')
+ .andWhere(`message.fromUserId NOT IN (${ mutingQuery.getQuery() })`)
+ .andWhere(`message.toUserId NOT IN (${ mutingQuery.getQuery() })`);
+
+ if (found.length > 0) {
+ query.andWhere('message.fromUserId NOT IN (:...found)', { found: found });
+ query.andWhere('message.toUserId NOT IN (:...found)', { found: found });
+ }
+
+ query.setParameters(mutingQuery.getParameters());
+
+ const message = await query.getOne();
+
+ if (message) {
+ history.push(message);
+ } else {
+ break;
+ }
+ }
+
+ return history;
+ }
+
+ @bindThis
+ public async roomHistory(meId: MiUser['id'], limit: number): Promise<MiChatMessage[]> {
+ // TODO: 一回のクエリにまとめられるかも
+ const [memberRoomIds, ownedRoomIds] = await Promise.all([
+ this.chatRoomMembershipsRepository.findBy({
+ userId: meId,
+ }).then(xs => xs.map(x => x.roomId)),
+ this.chatRoomsRepository.findBy({
+ ownerId: meId,
+ }).then(xs => xs.map(x => x.id)),
+ ]);
+
+ const roomIds = memberRoomIds.concat(ownedRoomIds);
+
+ if (memberRoomIds.length === 0 && ownedRoomIds.length === 0) {
+ return [];
+ }
+
+ const history: MiChatMessage[] = [];
+
+ for (let i = 0; i < limit; i++) {
+ const found = history.map(m => m.toRoomId!);
+
+ const query = this.chatMessagesRepository.createQueryBuilder('message')
+ .orderBy('message.id', 'DESC')
+ .where('message.toRoomId IN (:...roomIds)', { roomIds });
+
+ if (found.length > 0) {
+ query.andWhere('message.toRoomId NOT IN (:...found)', { found: found });
+ }
+
+ const message = await query.getOne();
+
+ if (message) {
+ history.push(message);
+ } else {
+ break;
+ }
+ }
+
+ return history;
+ }
+
+ @bindThis
+ public async getUserReadStateMap(userId: MiUser['id'], otherIds: MiUser['id'][]) {
+ const readStateMap: Record<MiUser['id'], boolean> = {};
+
+ const redisPipeline = this.redisClient.pipeline();
+
+ for (const otherId of otherIds) {
+ redisPipeline.get(`newUserChatMessageExists:${userId}:${otherId}`);
+ }
+
+ const markers = await redisPipeline.exec();
+ if (markers == null) throw new Error('redis error');
+
+ for (let i = 0; i < otherIds.length; i++) {
+ const marker = markers[i][1];
+ readStateMap[otherIds[i]] = marker == null;
+ }
+
+ return readStateMap;
+ }
+
+ @bindThis
+ public async getRoomReadStateMap(userId: MiUser['id'], roomIds: MiChatRoom['id'][]) {
+ const readStateMap: Record<MiChatRoom['id'], boolean> = {};
+
+ const redisPipeline = this.redisClient.pipeline();
+
+ for (const roomId of roomIds) {
+ redisPipeline.get(`newRoomChatMessageExists:${userId}:${roomId}`);
+ }
+
+ const markers = await redisPipeline.exec();
+ if (markers == null) throw new Error('redis error');
+
+ for (let i = 0; i < roomIds.length; i++) {
+ const marker = markers[i][1];
+ readStateMap[roomIds[i]] = marker == null;
+ }
+
+ return readStateMap;
+ }
+
+ @bindThis
+ public async hasUnreadMessages(userId: MiUser['id']) {
+ const card = await this.redisClient.scard(`newChatMessagesExists:${userId}`);
+ return card > 0;
+ }
+
+ @bindThis
+ public async createRoom(owner: MiUser, params: Partial<{
+ name: string;
+ description: string;
+ }>) {
+ const room = {
+ id: this.idService.gen(),
+ name: params.name,
+ description: params.description,
+ ownerId: owner.id,
+ } satisfies Partial<MiChatRoom>;
+
+ const created = await this.chatRoomsRepository.insertOne(room);
+
+ return created;
+ }
+
+ @bindThis
+ public async deleteRoom(room: MiChatRoom) {
+ await this.chatRoomsRepository.delete(room.id);
+ }
+
+ @bindThis
+ public async findMyRoomById(ownerId: MiUser['id'], roomId: MiChatRoom['id']) {
+ return this.chatRoomsRepository.findOneBy({ id: roomId, ownerId: ownerId });
+ }
+
+ @bindThis
+ public async findRoomById(roomId: MiChatRoom['id']) {
+ return this.chatRoomsRepository.findOne({ where: { id: roomId }, relations: ['owner'] });
+ }
+
+ @bindThis
+ public async isRoomMember(room: MiChatRoom, userId: MiUser['id']) {
+ if (room.ownerId === userId) return true;
+ const membership = await this.chatRoomMembershipsRepository.findOneBy({ roomId: room.id, userId });
+ return membership != null;
+ }
+
+ @bindThis
+ public async createRoomInvitation(inviterId: MiUser['id'], roomId: MiChatRoom['id'], inviteeId: MiUser['id']) {
+ if (inviterId === inviteeId) {
+ throw new Error('yourself');
+ }
+
+ const room = await this.chatRoomsRepository.findOneByOrFail({ id: roomId, ownerId: inviterId });
+
+ const existingInvitation = await this.chatRoomInvitationsRepository.findOneBy({ roomId, userId: inviteeId });
+ if (existingInvitation) {
+ throw new Error('already invited');
+ }
+
+ const membershipsCount = await this.chatRoomMembershipsRepository.countBy({ roomId });
+ if (membershipsCount >= MAX_ROOM_MEMBERS) {
+ throw new Error('room is full');
+ }
+
+ // TODO: cehck block
+
+ const invitation = {
+ id: this.idService.gen(),
+ roomId: room.id,
+ userId: inviteeId,
+ } satisfies Partial<MiChatRoomInvitation>;
+
+ const created = await this.chatRoomInvitationsRepository.insertOne(invitation);
+
+ return created;
+ }
+
+ @bindThis
+ public async getOwnedRoomsWithPagination(ownerId: MiUser['id'], limit: number, sinceId?: MiChatRoom['id'] | null, untilId?: MiChatRoom['id'] | null) {
+ const query = this.queryService.makePaginationQuery(this.chatRoomsRepository.createQueryBuilder('room'), sinceId, untilId)
+ .where('room.ownerId = :ownerId', { ownerId });
+
+ const rooms = await query.take(limit).getMany();
+
+ return rooms;
+ }
+
+ @bindThis
+ public async getReceivedRoomInvitationsWithPagination(userId: MiUser['id'], limit: number, sinceId?: MiChatRoomInvitation['id'] | null, untilId?: MiChatRoomInvitation['id'] | null) {
+ const query = this.queryService.makePaginationQuery(this.chatRoomInvitationsRepository.createQueryBuilder('invitation'), sinceId, untilId)
+ .where('invitation.userId = :userId', { userId })
+ .andWhere('invitation.ignored = FALSE');
+
+ const invitations = await query.take(limit).getMany();
+
+ return invitations;
+ }
+
+ @bindThis
+ public async joinToRoom(userId: MiUser['id'], roomId: MiChatRoom['id']) {
+ const invitation = await this.chatRoomInvitationsRepository.findOneByOrFail({ roomId, userId });
+
+ const membershipsCount = await this.chatRoomMembershipsRepository.countBy({ roomId });
+ if (membershipsCount >= MAX_ROOM_MEMBERS) {
+ throw new Error('room is full');
+ }
+
+ const membership = {
+ id: this.idService.gen(),
+ roomId: roomId,
+ userId: userId,
+ } satisfies Partial<MiChatRoomMembership>;
+
+ // TODO: transaction
+ await this.chatRoomMembershipsRepository.insertOne(membership);
+ await this.chatRoomInvitationsRepository.delete(invitation.id);
+ }
+
+ @bindThis
+ public async ignoreRoomInvitation(userId: MiUser['id'], roomId: MiChatRoom['id']) {
+ const invitation = await this.chatRoomInvitationsRepository.findOneByOrFail({ roomId, userId });
+ await this.chatRoomInvitationsRepository.update(invitation.id, { ignored: true });
+ }
+
+ @bindThis
+ public async leaveRoom(userId: MiUser['id'], roomId: MiChatRoom['id']) {
+ const membership = await this.chatRoomMembershipsRepository.findOneByOrFail({ roomId, userId });
+ await this.chatRoomMembershipsRepository.delete(membership.id);
+ }
+
+ @bindThis
+ public async muteRoom(userId: MiUser['id'], roomId: MiChatRoom['id'], mute: boolean) {
+ const membership = await this.chatRoomMembershipsRepository.findOneByOrFail({ roomId, userId });
+ await this.chatRoomMembershipsRepository.update(membership.id, { isMuted: mute });
+ }
+
+ @bindThis
+ public async updateRoom(room: MiChatRoom, params: {
+ name?: string;
+ description?: string;
+ }): Promise<MiChatRoom> {
+ return this.chatRoomsRepository.createQueryBuilder().update()
+ .set(params)
+ .where('id = :id', { id: room.id })
+ .returning('*')
+ .execute()
+ .then((response) => {
+ return response.raw[0];
+ });
+ }
+
+ @bindThis
+ public async getRoomMembershipsWithPagination(roomId: MiChatRoom['id'], limit: number, sinceId?: MiChatRoomMembership['id'] | null, untilId?: MiChatRoomMembership['id'] | null) {
+ const query = this.queryService.makePaginationQuery(this.chatRoomMembershipsRepository.createQueryBuilder('membership'), sinceId, untilId)
+ .where('membership.roomId = :roomId', { roomId });
+
+ const memberships = await query.take(limit).getMany();
+
+ return memberships;
+ }
+
+ @bindThis
+ public async searchMessages(meId: MiUser['id'], query: string, limit: number, params: {
+ userId?: MiUser['id'] | null;
+ roomId?: MiChatRoom['id'] | null;
+ }) {
+ const q = this.chatMessagesRepository.createQueryBuilder('message');
+
+ if (params.userId) {
+ q.andWhere(new Brackets(qb => {
+ qb
+ .where(new Brackets(qb => {
+ qb
+ .where('message.fromUserId = :meId')
+ .andWhere('message.toUserId = :otherId');
+ }))
+ .orWhere(new Brackets(qb => {
+ qb
+ .where('message.fromUserId = :otherId')
+ .andWhere('message.toUserId = :meId');
+ }));
+ }))
+ .setParameter('meId', meId)
+ .setParameter('otherId', params.userId);
+ } else if (params.roomId) {
+ q.where('message.toRoomId = :roomId', { roomId: params.roomId });
+ } else {
+ const membershipsQuery = this.chatRoomMembershipsRepository.createQueryBuilder('membership')
+ .select('membership.roomId')
+ .where('membership.userId = :meId', { meId: meId });
+
+ const ownedRoomsQuery = this.chatRoomsRepository.createQueryBuilder('room')
+ .select('room.id')
+ .where('room.ownerId = :meId', { meId });
+
+ q.andWhere(new Brackets(qb => {
+ qb
+ .where('message.fromUserId = :meId')
+ .orWhere('message.toUserId = :meId')
+ .orWhere(`message.toRoomId IN (${membershipsQuery.getQuery()})`)
+ .orWhere(`message.toRoomId IN (${ownedRoomsQuery.getQuery()})`);
+ }));
+
+ q.setParameters(membershipsQuery.getParameters());
+ q.setParameters(ownedRoomsQuery.getParameters());
+ }
+
+ q.andWhere('LOWER(message.text) LIKE :q', { q: `%${ sqlLikeEscape(query.toLowerCase()) }%` });
+
+ q.leftJoinAndSelect('message.file', 'file');
+ q.leftJoinAndSelect('message.fromUser', 'fromUser');
+ q.leftJoinAndSelect('message.toUser', 'toUser');
+ q.leftJoinAndSelect('message.toRoom', 'toRoom');
+ q.leftJoinAndSelect('toRoom.owner', 'toRoomOwner');
+
+ const messages = await q.orderBy('message.id', 'DESC').take(limit).getMany();
+
+ return messages;
+ }
+
+ @bindThis
+ public async react(messageId: MiChatMessage['id'], userId: MiUser['id'], reaction_: string) {
+ let reaction;
+
+ // TODO: ReactionServiceのやつと共通化
+ function normalize(x: string) {
+ const match = emojiRegex.exec(x);
+ if (match) {
+ // 合字を含む1つの絵文字
+ const unicode = match[0];
+
+ // 異体字セレクタ除去
+ return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, '');
+ } else {
+ throw new Error('invalid emoji');
+ }
+ }
+
+ const custom = reaction_.match(isCustomEmojiRegexp);
+
+ if (custom == null) {
+ reaction = normalize(reaction_);
+ } else {
+ const name = custom[1];
+ const emoji = (await this.customEmojiService.localEmojisCache.fetch()).get(name);
+
+ if (emoji == null) {
+ throw new Error('no such emoji');
+ } else {
+ reaction = `:${name}:`;
+ }
+ }
+
+ const message = await this.chatMessagesRepository.findOneByOrFail({ id: messageId });
+
+ if (message.fromUserId === userId) {
+ throw new Error('cannot react to own message');
+ }
+
+ if (message.toRoomId === null && message.toUserId !== userId) {
+ throw new Error('cannot react to others message');
+ }
+
+ if (message.reactions.length >= MAX_REACTIONS_PER_MESSAGE) {
+ throw new Error('too many reactions');
+ }
+
+ const room = message.toRoomId ? await this.chatRoomsRepository.findOneByOrFail({ id: message.toRoomId }) : null;
+
+ if (room) {
+ if (!await this.isRoomMember(room, userId)) {
+ throw new Error('cannot react to others message');
+ }
+ }
+
+ await this.chatMessagesRepository.createQueryBuilder().update()
+ .set({
+ reactions: () => `array_append("reactions", '${userId}/${reaction}')`,
+ })
+ .where('id = :id', { id: message.id })
+ .execute();
+
+ if (room) {
+ this.globalEventService.publishChatRoomStream(room.id, 'react', {
+ messageId: message.id,
+ user: await this.userEntityService.pack(userId),
+ reaction,
+ });
+ } else {
+ this.globalEventService.publishChatUserStream(message.fromUserId, message.toUserId!, 'react', {
+ messageId: message.id,
+ reaction,
+ });
+ this.globalEventService.publishChatUserStream(message.toUserId!, message.fromUserId, 'react', {
+ messageId: message.id,
+ reaction,
+ });
+ }
+ }
+
+ @bindThis
+ public async getMyMemberships(userId: MiUser['id'], limit: number, sinceId?: MiChatRoomMembership['id'] | null, untilId?: MiChatRoomMembership['id'] | null) {
+ const query = this.queryService.makePaginationQuery(this.chatRoomMembershipsRepository.createQueryBuilder('membership'), sinceId, untilId)
+ .where('membership.userId = :userId', { userId });
+
+ const memberships = await query.take(limit).getMany();
+
+ return memberships;
+ }
+}
diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts
index dc85a23e5b..d8617e343c 100644
--- a/packages/backend/src/core/CoreModule.ts
+++ b/packages/backend/src/core/CoreModule.ts
@@ -44,7 +44,6 @@ import { ModerationLogService } from './ModerationLogService.js';
import { NoteCreateService } from './NoteCreateService.js';
import { NoteDeleteService } from './NoteDeleteService.js';
import { NotePiningService } from './NotePiningService.js';
-import { NoteReadService } from './NoteReadService.js';
import { NotificationService } from './NotificationService.js';
import { PollService } from './PollService.js';
import { PushNotificationService } from './PushNotificationService.js';
@@ -75,6 +74,7 @@ import { ClipService } from './ClipService.js';
import { FeaturedService } from './FeaturedService.js';
import { FanoutTimelineService } from './FanoutTimelineService.js';
import { ChannelFollowingService } from './ChannelFollowingService.js';
+import { ChatService } from './ChatService.js';
import { RegistryApiService } from './RegistryApiService.js';
import { ReversiService } from './ReversiService.js';
@@ -100,6 +100,7 @@ import { AppEntityService } from './entities/AppEntityService.js';
import { AuthSessionEntityService } from './entities/AuthSessionEntityService.js';
import { BlockingEntityService } from './entities/BlockingEntityService.js';
import { ChannelEntityService } from './entities/ChannelEntityService.js';
+import { ChatEntityService } from './entities/ChatEntityService.js';
import { ClipEntityService } from './entities/ClipEntityService.js';
import { DriveFileEntityService } from './entities/DriveFileEntityService.js';
import { DriveFolderEntityService } from './entities/DriveFolderEntityService.js';
@@ -184,7 +185,6 @@ const $ModerationLogService: Provider = { provide: 'ModerationLogService', useEx
const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting: NoteCreateService };
const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService };
const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService };
-const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService };
const $NotificationService: Provider = { provide: 'NotificationService', useExisting: NotificationService };
const $PollService: Provider = { provide: 'PollService', useExisting: PollService };
const $SystemAccountService: Provider = { provide: 'SystemAccountService', useExisting: SystemAccountService };
@@ -221,6 +221,7 @@ const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: Fe
const $FanoutTimelineService: Provider = { provide: 'FanoutTimelineService', useExisting: FanoutTimelineService };
const $FanoutTimelineEndpointService: Provider = { provide: 'FanoutTimelineEndpointService', useExisting: FanoutTimelineEndpointService };
const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService };
+const $ChatService: Provider = { provide: 'ChatService', useExisting: ChatService };
const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService };
const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService };
@@ -247,6 +248,7 @@ const $AppEntityService: Provider = { provide: 'AppEntityService', useExisting:
const $AuthSessionEntityService: Provider = { provide: 'AuthSessionEntityService', useExisting: AuthSessionEntityService };
const $BlockingEntityService: Provider = { provide: 'BlockingEntityService', useExisting: BlockingEntityService };
const $ChannelEntityService: Provider = { provide: 'ChannelEntityService', useExisting: ChannelEntityService };
+const $ChatEntityService: Provider = { provide: 'ChatEntityService', useExisting: ChatEntityService };
const $ClipEntityService: Provider = { provide: 'ClipEntityService', useExisting: ClipEntityService };
const $DriveFileEntityService: Provider = { provide: 'DriveFileEntityService', useExisting: DriveFileEntityService };
const $DriveFolderEntityService: Provider = { provide: 'DriveFolderEntityService', useExisting: DriveFolderEntityService };
@@ -333,7 +335,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
NoteCreateService,
NoteDeleteService,
NotePiningService,
- NoteReadService,
NotificationService,
PollService,
SystemAccountService,
@@ -370,6 +371,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
FanoutTimelineService,
FanoutTimelineEndpointService,
ChannelFollowingService,
+ ChatService,
RegistryApiService,
ReversiService,
@@ -396,6 +398,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
AuthSessionEntityService,
BlockingEntityService,
ChannelEntityService,
+ ChatEntityService,
ClipEntityService,
DriveFileEntityService,
DriveFolderEntityService,
@@ -478,7 +481,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$NoteCreateService,
$NoteDeleteService,
$NotePiningService,
- $NoteReadService,
$NotificationService,
$PollService,
$SystemAccountService,
@@ -515,6 +517,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$FanoutTimelineService,
$FanoutTimelineEndpointService,
$ChannelFollowingService,
+ $ChatService,
$RegistryApiService,
$ReversiService,
@@ -541,6 +544,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$AuthSessionEntityService,
$BlockingEntityService,
$ChannelEntityService,
+ $ChatEntityService,
$ClipEntityService,
$DriveFileEntityService,
$DriveFolderEntityService,
@@ -624,7 +628,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
NoteCreateService,
NoteDeleteService,
NotePiningService,
- NoteReadService,
NotificationService,
PollService,
SystemAccountService,
@@ -661,6 +664,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
FanoutTimelineService,
FanoutTimelineEndpointService,
ChannelFollowingService,
+ ChatService,
RegistryApiService,
ReversiService,
@@ -686,6 +690,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
AuthSessionEntityService,
BlockingEntityService,
ChannelEntityService,
+ ChatEntityService,
ClipEntityService,
DriveFileEntityService,
DriveFolderEntityService,
@@ -768,7 +773,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$NoteCreateService,
$NoteDeleteService,
$NotePiningService,
- $NoteReadService,
$NotificationService,
$PollService,
$SystemAccountService,
@@ -804,6 +808,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$FanoutTimelineService,
$FanoutTimelineEndpointService,
$ChannelFollowingService,
+ $ChatService,
$RegistryApiService,
$ReversiService,
@@ -829,6 +834,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$AuthSessionEntityService,
$BlockingEntityService,
$ChannelEntityService,
+ $ChatEntityService,
$ClipEntityService,
$DriveFileEntityService,
$DriveFolderEntityService,
diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts
index 224fdabc4c..4da3b8bc78 100644
--- a/packages/backend/src/core/GlobalEventService.ts
+++ b/packages/backend/src/core/GlobalEventService.ts
@@ -20,7 +20,7 @@ import type { MiPage } from '@/models/Page.js';
import type { MiWebhook } from '@/models/Webhook.js';
import type { MiSystemWebhook } from '@/models/SystemWebhook.js';
import type { MiMeta } from '@/models/Meta.js';
-import { MiAvatarDecoration, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js';
+import { MiAvatarDecoration, MiChatMessage, MiChatRoom, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js';
import type { Packed } from '@/misc/json-schema.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
@@ -72,12 +72,8 @@ export interface MainEventTypes {
readAllNotifications: undefined;
notificationFlushed: undefined;
unreadNotification: Packed<'Notification'>;
- unreadMention: MiNote['id'];
- readAllUnreadMentions: undefined;
- unreadSpecifiedNote: MiNote['id'];
- readAllUnreadSpecifiedNotes: undefined;
- readAllAntennas: undefined;
unreadAntenna: MiAntenna;
+ newChatMessage: Packed<'ChatMessage'>;
readAllAnnouncements: undefined;
myTokenRegenerated: undefined;
signin: {
@@ -163,6 +159,16 @@ export interface AdminEventTypes {
};
}
+export interface ChatEventTypes {
+ message: Packed<'ChatMessageLite'>;
+ deleted: Packed<'ChatMessageLite'>['id'];
+ react: {
+ reaction: string;
+ user?: Packed<'UserLite'>;
+ messageId: MiChatMessage['id'];
+ };
+}
+
export interface ReversiEventTypes {
matched: {
game: Packed<'ReversiGameDetailed'>;
@@ -202,7 +208,7 @@ export interface ReversiGameEventTypes {
type Events<T extends object> = { [K in keyof T]: { type: K; body: T[K]; } };
type EventUnionFromDictionary<
T extends object,
- U = Events<T>
+ U = Events<T>,
> = U[keyof U];
type SerializedAll<T> = {
@@ -295,6 +301,14 @@ export type GlobalEvents = {
name: 'notesStream';
payload: Serialized<Packed<'Note'>>;
};
+ chat: {
+ name: `chatUserStream:${MiUser['id']}-${MiUser['id']}`;
+ payload: EventTypesToEventPayload<ChatEventTypes>;
+ };
+ chatRoom: {
+ name: `chatRoomStream:${MiChatRoom['id']}`;
+ payload: EventTypesToEventPayload<ChatEventTypes>;
+ };
reversi: {
name: `reversiStream:${MiUser['id']}`;
payload: EventTypesToEventPayload<ReversiEventTypes>;
@@ -394,6 +408,16 @@ export class GlobalEventService {
}
@bindThis
+ public publishChatUserStream<K extends keyof ChatEventTypes>(fromUserId: MiUser['id'], toUserId: MiUser['id'], type: K, value?: ChatEventTypes[K]): void {
+ this.publish(`chatUserStream:${fromUserId}-${toUserId}`, type, typeof value === 'undefined' ? null : value);
+ }
+
+ @bindThis
+ public publishChatRoomStream<K extends keyof ChatEventTypes>(toRoomId: MiChatRoom['id'], type: K, value?: ChatEventTypes[K]): void {
+ this.publish(`chatRoomStream:${toRoomId}`, type, typeof value === 'undefined' ? null : value);
+ }
+
+ @bindThis
public publishReversiStream<K extends keyof ReversiEventTypes>(userId: MiUser['id'], type: K, value?: ReversiEventTypes[K]): void {
this.publish(`reversiStream:${userId}`, type, typeof value === 'undefined' ? null : value);
}
diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts
index 8a79908e82..8f416f398c 100644
--- a/packages/backend/src/core/NoteCreateService.ts
+++ b/packages/backend/src/core/NoteCreateService.ts
@@ -42,7 +42,6 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
-import { NoteReadService } from '@/core/NoteReadService.js';
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
import { bindThis } from '@/decorators.js';
import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js';
@@ -199,7 +198,6 @@ export class NoteCreateService implements OnApplicationShutdown {
private globalEventService: GlobalEventService,
private queueService: QueueService,
private fanoutTimelineService: FanoutTimelineService,
- private noteReadService: NoteReadService,
private notificationService: NotificationService,
private relayService: RelayService,
private federatedInstanceService: FederatedInstanceService,
@@ -582,31 +580,6 @@ export class NoteCreateService implements OnApplicationShutdown {
if (!silent) {
if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user);
- // 未読通知を作成
- if (data.visibility === 'specified') {
- if (data.visibleUsers == null) throw new Error('invalid param');
-
- for (const u of data.visibleUsers) {
- // ローカルユーザーのみ
- if (!this.userEntityService.isLocalUser(u)) continue;
-
- this.noteReadService.insertNoteUnread(u.id, note, {
- isSpecified: true,
- isMentioned: false,
- });
- }
- } else {
- for (const u of mentionedUsers) {
- // ローカルユーザーのみ
- if (!this.userEntityService.isLocalUser(u)) continue;
-
- this.noteReadService.insertNoteUnread(u.id, note, {
- isSpecified: false,
- isMentioned: true,
- });
- }
- }
-
// Pack the note
const noteObj = await this.noteEntityService.pack(note, null, { skipHide: true, withReactionAndUserPairCache: true });
diff --git a/packages/backend/src/core/NoteReadService.ts b/packages/backend/src/core/NoteReadService.ts
deleted file mode 100644
index 181c9f7649..0000000000
--- a/packages/backend/src/core/NoteReadService.ts
+++ /dev/null
@@ -1,143 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { setTimeout } from 'node:timers/promises';
-import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
-import { In } from 'typeorm';
-import { DI } from '@/di-symbols.js';
-import type { MiUser } from '@/models/User.js';
-import type { Packed } from '@/misc/json-schema.js';
-import type { MiNote } from '@/models/Note.js';
-import { IdService } from '@/core/IdService.js';
-import { GlobalEventService } from '@/core/GlobalEventService.js';
-import type { NoteUnreadsRepository, MutingsRepository, NoteThreadMutingsRepository } from '@/models/_.js';
-import { bindThis } from '@/decorators.js';
-import { trackPromise } from '@/misc/promise-tracker.js';
-
-@Injectable()
-export class NoteReadService implements OnApplicationShutdown {
- #shutdownController = new AbortController();
-
- constructor(
- @Inject(DI.noteUnreadsRepository)
- private noteUnreadsRepository: NoteUnreadsRepository,
-
- @Inject(DI.mutingsRepository)
- private mutingsRepository: MutingsRepository,
-
- @Inject(DI.noteThreadMutingsRepository)
- private noteThreadMutingsRepository: NoteThreadMutingsRepository,
-
- private idService: IdService,
- private globalEventService: GlobalEventService,
- ) {
- }
-
- @bindThis
- public async insertNoteUnread(userId: MiUser['id'], note: MiNote, params: {
- // NOTE: isSpecifiedがtrueならisMentionedは必ずfalse
- isSpecified: boolean;
- isMentioned: boolean;
- }): Promise<void> {
- //#region ミュートしているなら無視
- const mute = await this.mutingsRepository.findBy({
- muterId: userId,
- });
- if (mute.map(m => m.muteeId).includes(note.userId)) return;
- //#endregion
-
- // スレッドミュート
- const isThreadMuted = await this.noteThreadMutingsRepository.exists({
- where: {
- userId: userId,
- threadId: note.threadId ?? note.id,
- },
- });
- if (isThreadMuted) return;
-
- const unread = {
- id: this.idService.gen(),
- noteId: note.id,
- userId: userId,
- isSpecified: params.isSpecified,
- isMentioned: params.isMentioned,
- noteUserId: note.userId,
- };
-
- await this.noteUnreadsRepository.insert(unread);
-
- // 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する
- setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => {
- const exist = await this.noteUnreadsRepository.exists({ where: { id: unread.id } });
-
- if (!exist) return;
-
- if (params.isMentioned) {
- this.globalEventService.publishMainStream(userId, 'unreadMention', note.id);
- }
- if (params.isSpecified) {
- this.globalEventService.publishMainStream(userId, 'unreadSpecifiedNote', note.id);
- }
- }, () => { /* aborted, ignore it */ });
- }
-
- @bindThis
- public async read(
- userId: MiUser['id'],
- notes: (MiNote | Packed<'Note'>)[],
- ): Promise<void> {
- if (notes.length === 0) return;
-
- const noteIds = new Set<MiNote['id']>();
-
- for (const note of notes) {
- if (note.mentions && note.mentions.includes(userId)) {
- noteIds.add(note.id);
- } else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) {
- noteIds.add(note.id);
- }
- }
-
- if (noteIds.size === 0) return;
-
- // Remove the record
- await this.noteUnreadsRepository.delete({
- userId: userId,
- noteId: In(Array.from(noteIds)),
- });
-
- // TODO: ↓まとめてクエリしたい
-
- trackPromise(this.noteUnreadsRepository.countBy({
- userId: userId,
- isMentioned: true,
- }).then(mentionsCount => {
- if (mentionsCount === 0) {
- // 全て既読になったイベントを発行
- this.globalEventService.publishMainStream(userId, 'readAllUnreadMentions');
- }
- }));
-
- trackPromise(this.noteUnreadsRepository.countBy({
- userId: userId,
- isSpecified: true,
- }).then(specifiedCount => {
- if (specifiedCount === 0) {
- // 全て既読になったイベントを発行
- this.globalEventService.publishMainStream(userId, 'readAllUnreadSpecifiedNotes');
- }
- }));
- }
-
- @bindThis
- public dispose(): void {
- this.#shutdownController.abort();
- }
-
- @bindThis
- public onApplicationShutdown(signal?: string | undefined): void {
- this.dispose();
- }
-}
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index 01f3e0c116..86f8a5caa1 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -63,6 +63,7 @@ export type RolePolicies = {
canImportFollowing: boolean;
canImportMuting: boolean;
canImportUserLists: boolean;
+ canChat: boolean;
};
export const DEFAULT_POLICIES: RolePolicies = {
@@ -97,6 +98,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
canImportFollowing: true,
canImportMuting: true,
canImportUserLists: true,
+ canChat: true,
};
@Injectable()
@@ -400,6 +402,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
canImportFollowing: calc('canImportFollowing', vs => vs.some(v => v === true)),
canImportMuting: calc('canImportMuting', vs => vs.some(v => v === true)),
canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)),
+ canChat: calc('canChat', vs => vs.some(v => v === true)),
};
}
diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts
index b98ca97ec9..e7a6be99fb 100644
--- a/packages/backend/src/core/UserFollowingService.ts
+++ b/packages/backend/src/core/UserFollowingService.ts
@@ -5,7 +5,7 @@
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
-import { IsNull } from 'typeorm';
+import { Brackets, IsNull } from 'typeorm';
import type { MiLocalUser, MiPartialLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { QueueService } from '@/core/QueueService.js';
@@ -736,4 +736,30 @@ export class UserFollowingService implements OnModuleInit {
.where('following.followerId = :followerId', { followerId: userId })
.getMany();
}
+
+ @bindThis
+ public isFollowing(followerId: MiUser['id'], followeeId: MiUser['id']) {
+ return this.followingsRepository.exists({
+ where: {
+ followerId,
+ followeeId,
+ },
+ });
+ }
+
+ @bindThis
+ public async isMutual(aUserId: MiUser['id'], bUserId: MiUser['id']) {
+ const count = await this.followingsRepository.createQueryBuilder('following')
+ .where(new Brackets(qb => {
+ qb.where('following.followerId = :aUserId', { aUserId })
+ .andWhere('following.followeeId = :bUserId', { bUserId });
+ }))
+ .orWhere(new Brackets(qb => {
+ qb.where('following.followerId = :bUserId', { bUserId })
+ .andWhere('following.followeeId = :aUserId', { aUserId });
+ }))
+ .getCount();
+
+ return count === 2;
+ }
}
diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts
index e1a7801bb1..a6198f7686 100644
--- a/packages/backend/src/core/WebhookTestService.ts
+++ b/packages/backend/src/core/WebhookTestService.ts
@@ -53,6 +53,7 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser {
requireSigninToViewContents: false,
makeNotesFollowersOnlyBefore: null,
makeNotesHiddenBefore: null,
+ chatScope: 'mutual',
emojis: [],
score: 0,
host: null,
@@ -461,6 +462,7 @@ export class WebhookTestService {
publicReactions: true,
followersVisibility: 'public',
followingVisibility: 'public',
+ chatScope: 'mutual',
twoFactorEnabled: false,
usePasswordLessLogin: false,
securityKeys: false,
diff --git a/packages/backend/src/core/entities/ChatEntityService.ts b/packages/backend/src/core/entities/ChatEntityService.ts
new file mode 100644
index 0000000000..099a9e3ad2
--- /dev/null
+++ b/packages/backend/src/core/entities/ChatEntityService.ts
@@ -0,0 +1,376 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { DI } from '@/di-symbols.js';
+import type { MiUser, ChatMessagesRepository, MiChatMessage, ChatRoomsRepository, MiChatRoom, MiChatRoomInvitation, ChatRoomInvitationsRepository, MiChatRoomMembership, ChatRoomMembershipsRepository } from '@/models/_.js';
+import { awaitAll } from '@/misc/prelude/await-all.js';
+import type { Packed } from '@/misc/json-schema.js';
+import type { } from '@/models/Blocking.js';
+import { bindThis } from '@/decorators.js';
+import { IdService } from '@/core/IdService.js';
+import { UserEntityService } from './UserEntityService.js';
+import { DriveFileEntityService } from './DriveFileEntityService.js';
+import { In } from 'typeorm';
+
+@Injectable()
+export class ChatEntityService {
+ constructor(
+ @Inject(DI.chatMessagesRepository)
+ private chatMessagesRepository: ChatMessagesRepository,
+
+ @Inject(DI.chatRoomsRepository)
+ private chatRoomsRepository: ChatRoomsRepository,
+
+ @Inject(DI.chatRoomInvitationsRepository)
+ private chatRoomInvitationsRepository: ChatRoomInvitationsRepository,
+
+ @Inject(DI.chatRoomMembershipsRepository)
+ private chatRoomMembershipsRepository: ChatRoomMembershipsRepository,
+
+ private userEntityService: UserEntityService,
+ private driveFileEntityService: DriveFileEntityService,
+ private idService: IdService,
+ ) {
+ }
+
+ @bindThis
+ public async packMessageDetailed(
+ src: MiChatMessage['id'] | MiChatMessage,
+ me?: { id: MiUser['id'] },
+ options?: {
+ _hint_?: {
+ packedFiles?: Map<MiChatMessage['fileId'], Packed<'DriveFile'> | null>;
+ packedUsers?: Map<MiChatMessage['id'], Packed<'UserLite'>>;
+ packedRooms?: Map<MiChatMessage['toRoomId'], Packed<'ChatRoom'> | null>;
+ };
+ },
+ ): Promise<Packed<'ChatMessage'>> {
+ const packedUsers = options?._hint_?.packedUsers;
+ const packedFiles = options?._hint_?.packedFiles;
+ const packedRooms = options?._hint_?.packedRooms;
+
+ const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src });
+
+ const reactions: { user: Packed<'UserLite'>; reaction: string; }[] = [];
+
+ for (const record of message.reactions) {
+ const [userId, reaction] = record.split('/');
+ reactions.push({
+ user: packedUsers?.get(userId) ?? await this.userEntityService.pack(userId),
+ reaction,
+ });
+ }
+
+ return {
+ id: message.id,
+ createdAt: this.idService.parse(message.id).date.toISOString(),
+ text: message.text,
+ fromUserId: message.fromUserId,
+ fromUser: packedUsers?.get(message.fromUserId) ?? await this.userEntityService.pack(message.fromUser ?? message.fromUserId, me),
+ toUserId: message.toUserId,
+ toUser: message.toUserId ? (packedUsers?.get(message.toUserId) ?? await this.userEntityService.pack(message.toUser ?? message.toUserId, me)) : undefined,
+ toRoomId: message.toRoomId,
+ toRoom: message.toRoomId ? (packedRooms?.get(message.toRoomId) ?? await this.packRoom(message.toRoom ?? message.toRoomId, me)) : undefined,
+ fileId: message.fileId,
+ file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null,
+ reactions,
+ };
+ }
+
+ @bindThis
+ public async packMessagesDetailed(
+ messages: MiChatMessage[],
+ me: { id: MiUser['id'] },
+ ) {
+ if (messages.length === 0) return [];
+
+ const excludeMe = (x: MiUser | string) => {
+ if (typeof x === 'string') {
+ return x !== me.id;
+ } else {
+ return x.id !== me.id;
+ }
+ };
+
+ const users = [
+ ...messages.map((m) => m.fromUser ?? m.fromUserId).filter(excludeMe),
+ ...messages.map((m) => m.toUser ?? m.toUserId).filter(x => x != null).filter(excludeMe),
+ ];
+
+ const reactedUserIds = messages.flatMap(x => x.reactions.map(r => r.split('/')[0]));
+
+ for (const reactedUserId of reactedUserIds) {
+ if (!users.some(x => typeof x === 'string' ? x === reactedUserId : x.id === reactedUserId)) {
+ users.push(reactedUserId);
+ }
+ }
+
+ const [packedUsers, packedFiles, packedRooms] = await Promise.all([
+ this.userEntityService.packMany(users, me)
+ .then(users => new Map(users.map(u => [u.id, u]))),
+ this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null))
+ .then(files => new Map(files.map(f => [f.id, f]))),
+ this.packRooms(messages.map(m => m.toRoom ?? m.toRoomId).filter(x => x != null), me)
+ .then(rooms => new Map(rooms.map(r => [r.id, r]))),
+ ]);
+
+ return Promise.all(messages.map(message => this.packMessageDetailed(message, me, { _hint_: { packedUsers, packedFiles, packedRooms } })));
+ }
+
+ @bindThis
+ public async packMessageLiteFor1on1(
+ src: MiChatMessage['id'] | MiChatMessage,
+ options?: {
+ _hint_?: {
+ packedFiles: Map<MiChatMessage['fileId'], Packed<'DriveFile'> | null>;
+ };
+ },
+ ): Promise<Packed<'ChatMessageLite'>> {
+ const packedFiles = options?._hint_?.packedFiles;
+
+ const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src });
+
+ const reactions: { reaction: string; }[] = [];
+
+ for (const record of message.reactions) {
+ const [userId, reaction] = record.split('/');
+ reactions.push({
+ reaction,
+ });
+ }
+
+ return {
+ id: message.id,
+ createdAt: this.idService.parse(message.id).date.toISOString(),
+ text: message.text,
+ fromUserId: message.fromUserId,
+ toUserId: message.toUserId,
+ fileId: message.fileId,
+ file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null,
+ reactions,
+ };
+ }
+
+ @bindThis
+ public async packMessagesLiteFor1on1(
+ messages: MiChatMessage[],
+ ) {
+ if (messages.length === 0) return [];
+
+ const [packedFiles] = await Promise.all([
+ this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null))
+ .then(files => new Map(files.map(f => [f.id, f]))),
+ ]);
+
+ return Promise.all(messages.map(message => this.packMessageLiteFor1on1(message, { _hint_: { packedFiles } })));
+ }
+
+ @bindThis
+ public async packMessageLiteForRoom(
+ src: MiChatMessage['id'] | MiChatMessage,
+ options?: {
+ _hint_?: {
+ packedFiles: Map<MiChatMessage['fileId'], Packed<'DriveFile'> | null>;
+ packedUsers: Map<MiUser['id'], Packed<'UserLite'>>;
+ };
+ },
+ ): Promise<Packed<'ChatMessageLite'>> {
+ const packedFiles = options?._hint_?.packedFiles;
+ const packedUsers = options?._hint_?.packedUsers;
+
+ const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src });
+
+ const reactions: { user: Packed<'UserLite'>; reaction: string; }[] = [];
+
+ for (const record of message.reactions) {
+ const [userId, reaction] = record.split('/');
+ reactions.push({
+ user: packedUsers?.get(userId) ?? await this.userEntityService.pack(userId),
+ reaction,
+ });
+ }
+
+ return {
+ id: message.id,
+ createdAt: this.idService.parse(message.id).date.toISOString(),
+ text: message.text,
+ fromUserId: message.fromUserId,
+ fromUser: packedUsers?.get(message.fromUserId) ?? await this.userEntityService.pack(message.fromUser ?? message.fromUserId),
+ toRoomId: message.toRoomId,
+ fileId: message.fileId,
+ file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null,
+ reactions,
+ };
+ }
+
+ @bindThis
+ public async packMessagesLiteForRoom(
+ messages: MiChatMessage[],
+ ) {
+ if (messages.length === 0) return [];
+
+ const users = messages.map(x => x.fromUser ?? x.fromUserId);
+ const reactedUserIds = messages.flatMap(x => x.reactions.map(r => r.split('/')[0]));
+
+ for (const reactedUserId of reactedUserIds) {
+ if (!users.some(x => typeof x === 'string' ? x === reactedUserId : x.id === reactedUserId)) {
+ users.push(reactedUserId);
+ }
+ }
+
+ const [packedUsers, packedFiles] = await Promise.all([
+ this.userEntityService.packMany(users)
+ .then(users => new Map(users.map(u => [u.id, u]))),
+ this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null))
+ .then(files => new Map(files.map(f => [f.id, f]))),
+ ]);
+
+ return Promise.all(messages.map(message => this.packMessageLiteForRoom(message, { _hint_: { packedFiles, packedUsers } })));
+ }
+
+ @bindThis
+ public async packRoom(
+ src: MiChatRoom['id'] | MiChatRoom,
+ me?: { id: MiUser['id'] },
+ options?: {
+ _hint_?: {
+ packedOwners: Map<MiChatRoom['id'], Packed<'UserLite'>>;
+ memberships?: Map<MiChatRoom['id'], MiChatRoomMembership | null | undefined>;
+ };
+ },
+ ): Promise<Packed<'ChatRoom'>> {
+ const room = typeof src === 'object' ? src : await this.chatRoomsRepository.findOneByOrFail({ id: src });
+
+ const membership = me && me.id !== room.ownerId ? (options?._hint_?.memberships?.get(room.id) ?? await this.chatRoomMembershipsRepository.findOneBy({ roomId: room.id, userId: me.id })) : null;
+
+ return {
+ id: room.id,
+ createdAt: this.idService.parse(room.id).date.toISOString(),
+ name: room.name,
+ description: room.description,
+ ownerId: room.ownerId,
+ owner: options?._hint_?.packedOwners.get(room.ownerId) ?? await this.userEntityService.pack(room.owner ?? room.ownerId, me),
+ isMuted: membership != null ? membership.isMuted : false,
+ };
+ }
+
+ @bindThis
+ public async packRooms(
+ rooms: (MiChatRoom | MiChatRoom['id'])[],
+ me: { id: MiUser['id'] },
+ ) {
+ if (rooms.length === 0) return [];
+
+ const _rooms = rooms.filter((room): room is MiChatRoom => typeof room !== 'string');
+ if (_rooms.length !== rooms.length) {
+ _rooms.push(
+ ...await this.chatRoomsRepository.find({
+ where: {
+ id: In(rooms.filter((room): room is string => typeof room === 'string')),
+ },
+ relations: ['owner'],
+ }),
+ );
+ }
+
+ const owners = _rooms.map(x => x.owner ?? x.ownerId);
+
+ const [packedOwners, memberships] = await Promise.all([
+ this.userEntityService.packMany(owners, me)
+ .then(users => new Map(users.map(u => [u.id, u]))),
+ this.chatRoomMembershipsRepository.find({
+ where: {
+ roomId: In(_rooms.map(x => x.id)),
+ userId: me.id,
+ },
+ }).then(memberships => new Map(_rooms.map(r => [r.id, memberships.find(m => m.roomId === r.id)]))),
+ ]);
+
+ return Promise.all(_rooms.map(room => this.packRoom(room, me, { _hint_: { packedOwners, memberships } })));
+ }
+
+ @bindThis
+ public async packRoomInvitation(
+ src: MiChatRoomInvitation['id'] | MiChatRoomInvitation,
+ me: { id: MiUser['id'] },
+ options?: {
+ _hint_?: {
+ packedRooms: Map<MiChatRoomInvitation['roomId'], Packed<'ChatRoom'>>;
+ packedUsers: Map<MiChatRoomInvitation['id'], Packed<'UserLite'>>;
+ };
+ },
+ ): Promise<Packed<'ChatRoomInvitation'>> {
+ const invitation = typeof src === 'object' ? src : await this.chatRoomInvitationsRepository.findOneByOrFail({ id: src });
+
+ return {
+ id: invitation.id,
+ createdAt: this.idService.parse(invitation.id).date.toISOString(),
+ roomId: invitation.roomId,
+ room: options?._hint_?.packedRooms.get(invitation.roomId) ?? await this.packRoom(invitation.room ?? invitation.roomId, me),
+ userId: invitation.userId,
+ user: options?._hint_?.packedUsers.get(invitation.userId) ?? await this.userEntityService.pack(invitation.user ?? invitation.userId, me),
+ };
+ }
+
+ @bindThis
+ public async packRoomInvitations(
+ invitations: MiChatRoomInvitation[],
+ me: { id: MiUser['id'] },
+ ) {
+ if (invitations.length === 0) return [];
+
+ return Promise.all(invitations.map(invitation => this.packRoomInvitation(invitation, me)));
+ }
+
+ @bindThis
+ public async packRoomMembership(
+ src: MiChatRoomMembership['id'] | MiChatRoomMembership,
+ me: { id: MiUser['id'] },
+ options?: {
+ populateUser?: boolean;
+ populateRoom?: boolean;
+ _hint_?: {
+ packedRooms: Map<MiChatRoomMembership['roomId'], Packed<'ChatRoom'>>;
+ packedUsers: Map<MiChatRoomMembership['id'], Packed<'UserLite'>>;
+ };
+ },
+ ): Promise<Packed<'ChatRoomMembership'>> {
+ const membership = typeof src === 'object' ? src : await this.chatRoomMembershipsRepository.findOneByOrFail({ id: src });
+
+ return {
+ id: membership.id,
+ createdAt: this.idService.parse(membership.id).date.toISOString(),
+ userId: membership.userId,
+ user: options?.populateUser ? (options._hint_?.packedUsers.get(membership.userId) ?? await this.userEntityService.pack(membership.user ?? membership.userId, me)) : undefined,
+ roomId: membership.roomId,
+ room: options?.populateRoom ? (options._hint_?.packedRooms.get(membership.roomId) ?? await this.packRoom(membership.room ?? membership.roomId, me)) : undefined,
+ };
+ }
+
+ @bindThis
+ public async packRoomMemberships(
+ memberships: MiChatRoomMembership[],
+ me: { id: MiUser['id'] },
+ options: {
+ populateUser?: boolean;
+ populateRoom?: boolean;
+ } = {},
+ ) {
+ if (memberships.length === 0) return [];
+
+ const users = memberships.map(x => x.user ?? x.userId);
+ const rooms = memberships.map(x => x.room ?? x.roomId);
+
+ const [packedUsers, packedRooms] = await Promise.all([
+ this.userEntityService.packMany(users, me)
+ .then(users => new Map(users.map(u => [u.id, u]))),
+ this.packRooms(rooms, me)
+ .then(rooms => new Map(rooms.map(r => [r.id, r]))),
+ ]);
+
+ return Promise.all(memberships.map(membership => this.packRoomMembership(membership, me, { ...options, _hint_: { packedUsers, packedRooms } })));
+ }
+}
diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts
index 69f698d9cb..92a684dc1a 100644
--- a/packages/backend/src/core/entities/UserEntityService.ts
+++ b/packages/backend/src/core/entities/UserEntityService.ts
@@ -32,7 +32,6 @@ import type {
MiUserNotePining,
MiUserProfile,
MutingsRepository,
- NoteUnreadsRepository,
RenoteMutingsRepository,
UserMemoRepository,
UserNotePiningsRepository,
@@ -48,9 +47,9 @@ import { IdService } from '@/core/IdService.js';
import type { AnnouncementService } from '@/core/AnnouncementService.js';
import type { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { AvatarDecorationService } from '@/core/AvatarDecorationService.js';
+import { ChatService } from '@/core/ChatService.js';
import type { OnModuleInit } from '@nestjs/common';
import type { NoteEntityService } from './NoteEntityService.js';
-import type { DriveFileEntityService } from './DriveFileEntityService.js';
import type { PageEntityService } from './PageEntityService.js';
const Ajv = _Ajv.default;
@@ -94,6 +93,7 @@ export class UserEntityService implements OnModuleInit {
private federatedInstanceService: FederatedInstanceService;
private idService: IdService;
private avatarDecorationService: AvatarDecorationService;
+ private chatService: ChatService;
constructor(
private moduleRef: ModuleRef,
@@ -128,9 +128,6 @@ export class UserEntityService implements OnModuleInit {
@Inject(DI.renoteMutingsRepository)
private renoteMutingsRepository: RenoteMutingsRepository,
- @Inject(DI.noteUnreadsRepository)
- private noteUnreadsRepository: NoteUnreadsRepository,
-
@Inject(DI.userNotePiningsRepository)
private userNotePiningsRepository: UserNotePiningsRepository,
@@ -152,6 +149,7 @@ export class UserEntityService implements OnModuleInit {
this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService');
this.idService = this.moduleRef.get('IdService');
this.avatarDecorationService = this.moduleRef.get('AvatarDecorationService');
+ this.chatService = this.moduleRef.get('ChatService');
}
//#region Validators
@@ -558,6 +556,7 @@ export class UserEntityService implements OnModuleInit {
publicReactions: this.isLocalUser(user) ? profile!.publicReactions : false, // https://github.com/misskey-dev/misskey/issues/12964
followersVisibility: profile!.followersVisibility,
followingVisibility: profile!.followingVisibility,
+ chatScope: user.chatScope,
roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({
id: role.id,
name: role.name,
@@ -598,14 +597,9 @@ export class UserEntityService implements OnModuleInit {
isDeleted: user.isDeleted,
twoFactorBackupCodesStock: profile?.twoFactorBackupSecret?.length === 5 ? 'full' : (profile?.twoFactorBackupSecret?.length ?? 0) > 0 ? 'partial' : 'none',
hideOnlineStatus: user.hideOnlineStatus,
- hasUnreadSpecifiedNotes: this.noteUnreadsRepository.count({
- where: { userId: user.id, isSpecified: true },
- take: 1,
- }).then(count => count > 0),
- hasUnreadMentions: this.noteUnreadsRepository.count({
- where: { userId: user.id, isMentioned: true },
- take: 1,
- }).then(count => count > 0),
+ hasUnreadSpecifiedNotes: false, // 後方互換性のため
+ hasUnreadMentions: false, // 後方互換性のため
+ hasUnreadChatMessages: this.chatService.hasUnreadMessages(user.id),
hasUnreadAnnouncement: unreadAnnouncements!.length > 0,
unreadAnnouncements,
hasUnreadAntenna: this.getHasUnreadAntenna(user.id),
diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts
index a306aac1a1..77d2838e09 100644
--- a/packages/backend/src/di-symbols.ts
+++ b/packages/backend/src/di-symbols.ts
@@ -24,7 +24,6 @@ export const DI = {
noteFavoritesRepository: Symbol('noteFavoritesRepository'),
noteThreadMutingsRepository: Symbol('noteThreadMutingsRepository'),
noteReactionsRepository: Symbol('noteReactionsRepository'),
- noteUnreadsRepository: Symbol('noteUnreadsRepository'),
pollsRepository: Symbol('pollsRepository'),
pollVotesRepository: Symbol('pollVotesRepository'),
userProfilesRepository: Symbol('userProfilesRepository'),
@@ -83,6 +82,11 @@ export const DI = {
flashsRepository: Symbol('flashsRepository'),
flashLikesRepository: Symbol('flashLikesRepository'),
userMemosRepository: Symbol('userMemosRepository'),
+ chatMessagesRepository: Symbol('chatMessagesRepository'),
+ chatApprovalsRepository: Symbol('chatApprovalsRepository'),
+ chatRoomsRepository: Symbol('chatRoomsRepository'),
+ chatRoomMembershipsRepository: Symbol('chatRoomMembershipsRepository'),
+ chatRoomInvitationsRepository: Symbol('chatRoomInvitationsRepository'),
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
reversiGamesRepository: Symbol('reversiGamesRepository'),
//#endregion
diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts
index 100fb69585..bc9308ca9b 100644
--- a/packages/backend/src/misc/json-schema.ts
+++ b/packages/backend/src/misc/json-schema.ts
@@ -63,6 +63,10 @@ import {
} from '@/models/json-schema/meta.js';
import { packedSystemWebhookSchema } from '@/models/json-schema/system-webhook.js';
import { packedAbuseReportNotificationRecipientSchema } from '@/models/json-schema/abuse-report-notification-recipient.js';
+import { packedChatMessageSchema, packedChatMessageLiteSchema } from '@/models/json-schema/chat-message.js';
+import { packedChatRoomSchema } from '@/models/json-schema/chat-room.js';
+import { packedChatRoomInvitationSchema } from '@/models/json-schema/chat-room-invitation.js';
+import { packedChatRoomMembershipSchema } from '@/models/json-schema/chat-room-membership.js';
export const refs = {
UserLite: packedUserLiteSchema,
@@ -120,6 +124,11 @@ export const refs = {
MetaDetailed: packedMetaDetailedSchema,
SystemWebhook: packedSystemWebhookSchema,
AbuseReportNotificationRecipient: packedAbuseReportNotificationRecipientSchema,
+ ChatMessage: packedChatMessageSchema,
+ ChatMessageLite: packedChatMessageLiteSchema,
+ ChatRoom: packedChatRoomSchema,
+ ChatRoomInvitation: packedChatRoomInvitationSchema,
+ ChatRoomMembership: packedChatRoomMembershipSchema,
};
export type Packed<x extends keyof typeof refs> = SchemaType<typeof refs[x]>;
@@ -171,11 +180,11 @@ export interface Schema extends OfSchema {
type RequiredPropertyNames<s extends Obj> = {
[K in keyof s]:
- // K is not optional
- s[K]['optional'] extends false ? K :
- // K has default value
- s[K]['default'] extends null | string | number | boolean | Record<string, unknown> ? K :
- never
+ // K is not optional
+ s[K]['optional'] extends false ? K :
+ // K has default value
+ s[K]['default'] extends null | string | number | boolean | Record<string, unknown> ? K :
+ never
}[keyof s];
export type Obj = Record<string, Schema>;
@@ -214,18 +223,18 @@ type ObjectSchemaTypeDef<p extends Schema> =
p['anyOf'] extends ReadonlyArray<Schema> ? p['anyOf'][number]['required'] extends ReadonlyArray<keyof p['properties']> ?
UnionObjType<p['properties'], NonNullable<p['anyOf'][number]['required']>> & ObjType<p['properties'], NonNullable<p['required']>>
: never
- : ObjType<p['properties'], NonNullable<p['required']>>
- :
- p['anyOf'] extends ReadonlyArray<Schema> ? never : // see CONTRIBUTING.md
- p['allOf'] extends ReadonlyArray<Schema> ? UnionToIntersection<UnionSchemaType<p['allOf']>> :
- p['additionalProperties'] extends true ? Record<string, any> :
- p['additionalProperties'] extends Schema ?
- p['additionalProperties'] extends infer AdditionalProperties ?
- AdditionalProperties extends Schema ?
- Record<string, SchemaType<AdditionalProperties>> :
+ : ObjType<p['properties'], NonNullable<p['required']>>
+ :
+ p['anyOf'] extends ReadonlyArray<Schema> ? never : // see CONTRIBUTING.md
+ p['allOf'] extends ReadonlyArray<Schema> ? UnionToIntersection<UnionSchemaType<p['allOf']>> :
+ p['additionalProperties'] extends true ? Record<string, any> :
+ p['additionalProperties'] extends Schema ?
+ p['additionalProperties'] extends infer AdditionalProperties ?
+ AdditionalProperties extends Schema ?
+ Record<string, SchemaType<AdditionalProperties>> :
+ never :
never :
- never :
- any;
+ any;
type ObjectSchemaType<p extends Schema> = NullOrUndefined<p, ObjectSchemaTypeDef<p>>;
@@ -235,30 +244,30 @@ export type SchemaTypeDef<p extends Schema> =
p['type'] extends 'number' ? number :
p['type'] extends 'string' ? (
p['enum'] extends readonly (string | null)[] ?
- p['enum'][number] :
- p['format'] extends 'date-time' ? string : // Dateにする??
- string
+ p['enum'][number] :
+ p['format'] extends 'date-time' ? string : // Dateにする??
+ string
) :
- p['type'] extends 'boolean' ? boolean :
- p['type'] extends 'object' ? ObjectSchemaTypeDef<p> :
- p['type'] extends 'array' ? (
- p['items'] extends OfSchema ? (
- p['items']['anyOf'] extends ReadonlyArray<Schema> ? UnionSchemaType<NonNullable<p['items']['anyOf']>>[] :
- p['items']['oneOf'] extends ReadonlyArray<Schema> ? ArrayUnion<UnionSchemaType<NonNullable<p['items']['oneOf']>>> :
- p['items']['allOf'] extends ReadonlyArray<Schema> ? UnionToIntersection<UnionSchemaType<NonNullable<p['items']['allOf']>>>[] :
- never
- ) :
- p['prefixItems'] extends ReadonlyArray<Schema> ? (
- p['items'] extends NonNullable<Schema> ? [...ArrayToTuple<p['prefixItems']>, ...SchemaType<p['items']>[]] :
- p['items'] extends false ? ArrayToTuple<p['prefixItems']> :
- p['unevaluatedItems'] extends false ? ArrayToTuple<p['prefixItems']> :
- [...ArrayToTuple<p['prefixItems']>, ...unknown[]]
+ p['type'] extends 'boolean' ? boolean :
+ p['type'] extends 'object' ? ObjectSchemaTypeDef<p> :
+ p['type'] extends 'array' ? (
+ p['items'] extends OfSchema ? (
+ p['items']['anyOf'] extends ReadonlyArray<Schema> ? UnionSchemaType<NonNullable<p['items']['anyOf']>>[] :
+ p['items']['oneOf'] extends ReadonlyArray<Schema> ? ArrayUnion<UnionSchemaType<NonNullable<p['items']['oneOf']>>> :
+ p['items']['allOf'] extends ReadonlyArray<Schema> ? UnionToIntersection<UnionSchemaType<NonNullable<p['items']['allOf']>>>[] :
+ never
+ ) :
+ p['prefixItems'] extends ReadonlyArray<Schema> ? (
+ p['items'] extends NonNullable<Schema> ? [...ArrayToTuple<p['prefixItems']>, ...SchemaType<p['items']>[]] :
+ p['items'] extends false ? ArrayToTuple<p['prefixItems']> :
+ p['unevaluatedItems'] extends false ? ArrayToTuple<p['prefixItems']> :
+ [...ArrayToTuple<p['prefixItems']>, ...unknown[]]
+ ) :
+ p['items'] extends NonNullable<Schema> ? SchemaType<p['items']>[] :
+ any[]
) :
- p['items'] extends NonNullable<Schema> ? SchemaType<p['items']>[] :
- any[]
- ) :
- p['anyOf'] extends ReadonlyArray<Schema> ? UnionSchemaType<p['anyOf']> & PartialIntersection<UnionSchemaType<p['anyOf']>> :
- p['oneOf'] extends ReadonlyArray<Schema> ? UnionSchemaType<p['oneOf']> :
- any;
+ p['anyOf'] extends ReadonlyArray<Schema> ? UnionSchemaType<p['anyOf']> & PartialIntersection<UnionSchemaType<p['anyOf']>> :
+ p['oneOf'] extends ReadonlyArray<Schema> ? UnionSchemaType<p['oneOf']> :
+ any;
export type SchemaType<p extends Schema> = NullOrUndefined<p, SchemaTypeDef<p>>;
diff --git a/packages/backend/src/models/ChatApproval.ts b/packages/backend/src/models/ChatApproval.ts
new file mode 100644
index 0000000000..55c9f07e9a
--- /dev/null
+++ b/packages/backend/src/models/ChatApproval.ts
@@ -0,0 +1,39 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
+import { id } from './util/id.js';
+import { MiUser } from './User.js';
+
+@Entity('chat_approval')
+@Index(['userId', 'otherId'], { unique: true })
+export class MiChatApproval {
+ @PrimaryColumn(id())
+ public id: string;
+
+ @Index()
+ @Column({
+ ...id(),
+ })
+ public userId: MiUser['id'];
+
+ @ManyToOne(type => MiUser, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn()
+ public user: MiUser | null;
+
+ @Index()
+ @Column({
+ ...id(),
+ })
+ public otherId: MiUser['id'];
+
+ @ManyToOne(type => MiUser, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn()
+ public other: MiUser | null;
+}
diff --git a/packages/backend/src/models/ChatMessage.ts b/packages/backend/src/models/ChatMessage.ts
new file mode 100644
index 0000000000..3d2b64268e
--- /dev/null
+++ b/packages/backend/src/models/ChatMessage.ts
@@ -0,0 +1,85 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
+import { id } from './util/id.js';
+import { MiUser } from './User.js';
+import { MiDriveFile } from './DriveFile.js';
+import { MiChatRoom } from './ChatRoom.js';
+
+@Entity('chat_message')
+export class MiChatMessage {
+ @PrimaryColumn(id())
+ public id: string;
+
+ @Index()
+ @Column({
+ ...id(),
+ })
+ public fromUserId: MiUser['id'];
+
+ @ManyToOne(type => MiUser, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn()
+ public fromUser: MiUser | null;
+
+ @Index()
+ @Column({
+ ...id(), nullable: true,
+ })
+ public toUserId: MiUser['id'] | null;
+
+ @ManyToOne(type => MiUser, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn()
+ public toUser: MiUser | null;
+
+ @Index()
+ @Column({
+ ...id(), nullable: true,
+ })
+ public toRoomId: MiChatRoom['id'] | null;
+
+ @ManyToOne(type => MiChatRoom, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn()
+ public toRoom: MiChatRoom | null;
+
+ @Column('varchar', {
+ length: 4096, nullable: true,
+ })
+ public text: string | null;
+
+ @Column('varchar', {
+ length: 512, nullable: true,
+ })
+ public uri: string | null;
+
+ @Column({
+ ...id(),
+ array: true, default: '{}',
+ })
+ public reads: MiUser['id'][];
+
+ @Column({
+ ...id(),
+ nullable: true,
+ })
+ public fileId: MiDriveFile['id'] | null;
+
+ @ManyToOne(type => MiDriveFile, {
+ onDelete: 'SET NULL',
+ })
+ @JoinColumn()
+ public file: MiDriveFile | null;
+
+ @Column('varchar', {
+ length: 1024, array: true, default: '{}',
+ })
+ public reactions: string[];
+}
diff --git a/packages/backend/src/models/ChatRoom.ts b/packages/backend/src/models/ChatRoom.ts
new file mode 100644
index 0000000000..ad2a910b78
--- /dev/null
+++ b/packages/backend/src/models/ChatRoom.ts
@@ -0,0 +1,41 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
+import { id } from './util/id.js';
+import { MiUser } from './User.js';
+
+@Entity('chat_room')
+export class MiChatRoom {
+ @PrimaryColumn(id())
+ public id: string;
+
+ @Column('varchar', {
+ length: 256,
+ })
+ public name: string;
+
+ @Index()
+ @Column({
+ ...id(),
+ })
+ public ownerId: MiUser['id'];
+
+ @ManyToOne(type => MiUser, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn()
+ public owner: MiUser | null;
+
+ @Column('varchar', {
+ length: 2048, default: '',
+ })
+ public description: string;
+
+ @Column('boolean', {
+ default: false,
+ })
+ public isArchived: boolean;
+}
diff --git a/packages/backend/src/models/ChatRoomInvitation.ts b/packages/backend/src/models/ChatRoomInvitation.ts
new file mode 100644
index 0000000000..36ce12bc92
--- /dev/null
+++ b/packages/backend/src/models/ChatRoomInvitation.ts
@@ -0,0 +1,45 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
+import { id } from './util/id.js';
+import { MiUser } from './User.js';
+import { MiChatRoom } from './ChatRoom.js';
+
+@Entity('chat_room_invitation')
+@Index(['userId', 'roomId'], { unique: true })
+export class MiChatRoomInvitation {
+ @PrimaryColumn(id())
+ public id: string;
+
+ @Index()
+ @Column({
+ ...id(),
+ })
+ public userId: MiUser['id'];
+
+ @ManyToOne(type => MiUser, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn()
+ public user: MiUser | null;
+
+ @Index()
+ @Column({
+ ...id(),
+ })
+ public roomId: MiChatRoom['id'];
+
+ @ManyToOne(type => MiChatRoom, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn()
+ public room: MiChatRoom | null;
+
+ @Column('boolean', {
+ default: false,
+ })
+ public ignored: boolean;
+}
diff --git a/packages/backend/src/models/ChatRoomMembership.ts b/packages/backend/src/models/ChatRoomMembership.ts
new file mode 100644
index 0000000000..3cb5524859
--- /dev/null
+++ b/packages/backend/src/models/ChatRoomMembership.ts
@@ -0,0 +1,45 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
+import { id } from './util/id.js';
+import { MiUser } from './User.js';
+import { MiChatRoom } from './ChatRoom.js';
+
+@Entity('chat_room_membership')
+@Index(['userId', 'roomId'], { unique: true })
+export class MiChatRoomMembership {
+ @PrimaryColumn(id())
+ public id: string;
+
+ @Index()
+ @Column({
+ ...id(),
+ })
+ public userId: MiUser['id'];
+
+ @ManyToOne(type => MiUser, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn()
+ public user: MiUser | null;
+
+ @Index()
+ @Column({
+ ...id(),
+ })
+ public roomId: MiChatRoom['id'];
+
+ @ManyToOne(type => MiChatRoom, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn()
+ public room: MiChatRoom | null;
+
+ @Column('boolean', {
+ default: false,
+ })
+ public isMuted: boolean;
+}
diff --git a/packages/backend/src/models/NoteUnread.ts b/packages/backend/src/models/NoteUnread.ts
deleted file mode 100644
index c759181117..0000000000
--- a/packages/backend/src/models/NoteUnread.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
-import { id } from './util/id.js';
-import { MiUser } from './User.js';
-import { MiNote } from './Note.js';
-import type { MiChannel } from './Channel.js';
-
-@Entity('note_unread')
-@Index(['userId', 'noteId'], { unique: true })
-export class MiNoteUnread {
- @PrimaryColumn(id())
- public id: string;
-
- @Index()
- @Column(id())
- public userId: MiUser['id'];
-
- @ManyToOne(type => MiUser, {
- onDelete: 'CASCADE',
- })
- @JoinColumn()
- public user: MiUser | null;
-
- @Index()
- @Column(id())
- public noteId: MiNote['id'];
-
- @ManyToOne(type => MiNote, {
- onDelete: 'CASCADE',
- })
- @JoinColumn()
- public note: MiNote | null;
-
- /**
- * メンションか否か
- */
- @Index()
- @Column('boolean')
- public isMentioned: boolean;
-
- /**
- * ダイレクト投稿か否か
- */
- @Index()
- @Column('boolean')
- public isSpecified: boolean;
-
- //#region Denormalized fields
- @Index()
- @Column({
- ...id(),
- comment: '[Denormalized]',
- })
- public noteUserId: MiUser['id'];
-
- @Index()
- @Column({
- ...id(),
- nullable: true,
- comment: '[Denormalized]',
- })
- public noteChannelId: MiChannel['id'] | null;
- //#endregion
-}
diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts
index 04a9df6cfb..b7142d91bf 100644
--- a/packages/backend/src/models/RepositoryModule.ts
+++ b/packages/backend/src/models/RepositoryModule.ts
@@ -42,7 +42,6 @@ import {
MiNoteFavorite,
MiNoteReaction,
MiNoteThreadMuting,
- MiNoteUnread,
MiPage,
MiPageLike,
MiPasswordResetRequest,
@@ -78,6 +77,11 @@ import {
MiUserPublickey,
MiUserSecurityKey,
MiWebhook,
+ MiChatMessage,
+ MiChatRoom,
+ MiChatRoomMembership,
+ MiChatRoomInvitation,
+ MiChatApproval,
} from './_.js';
import type { Provider } from '@nestjs/common';
import type { DataSource } from 'typeorm';
@@ -136,12 +140,6 @@ const $noteReactionsRepository: Provider = {
inject: [DI.db],
};
-const $noteUnreadsRepository: Provider = {
- provide: DI.noteUnreadsRepository,
- useFactory: (db: DataSource) => db.getRepository(MiNoteUnread).extend(miRepository as MiRepository<MiNoteUnread>),
- inject: [DI.db],
-};
-
const $pollsRepository: Provider = {
provide: DI.pollsRepository,
useFactory: (db: DataSource) => db.getRepository(MiPoll).extend(miRepository as MiRepository<MiPoll>),
@@ -288,7 +286,7 @@ const $swSubscriptionsRepository: Provider = {
const $systemAccountsRepository: Provider = {
provide: DI.systemAccountsRepository,
- useFactory: (db: DataSource) => db.getRepository(MiSystemAccount),
+ useFactory: (db: DataSource) => db.getRepository(MiSystemAccount).extend(miRepository as MiRepository<MiSystemAccount>),
inject: [DI.db],
};
@@ -306,7 +304,7 @@ const $abuseUserReportsRepository: Provider = {
const $abuseReportNotificationRecipientRepository: Provider = {
provide: DI.abuseReportNotificationRecipientRepository,
- useFactory: (db: DataSource) => db.getRepository(MiAbuseReportNotificationRecipient),
+ useFactory: (db: DataSource) => db.getRepository(MiAbuseReportNotificationRecipient).extend(miRepository as MiRepository<MiAbuseReportNotificationRecipient>),
inject: [DI.db],
};
@@ -438,7 +436,7 @@ const $webhooksRepository: Provider = {
const $systemWebhooksRepository: Provider = {
provide: DI.systemWebhooksRepository,
- useFactory: (db: DataSource) => db.getRepository(MiSystemWebhook),
+ useFactory: (db: DataSource) => db.getRepository(MiSystemWebhook).extend(miRepository as MiRepository<MiSystemWebhook>),
inject: [DI.db],
};
@@ -490,6 +488,36 @@ const $userMemosRepository: Provider = {
inject: [DI.db],
};
+const $chatMessagesRepository: Provider = {
+ provide: DI.chatMessagesRepository,
+ useFactory: (db: DataSource) => db.getRepository(MiChatMessage).extend(miRepository as MiRepository<MiChatMessage>),
+ inject: [DI.db],
+};
+
+const $chatRoomsRepository: Provider = {
+ provide: DI.chatRoomsRepository,
+ useFactory: (db: DataSource) => db.getRepository(MiChatRoom).extend(miRepository as MiRepository<MiChatRoom>),
+ inject: [DI.db],
+};
+
+const $chatRoomMembershipsRepository: Provider = {
+ provide: DI.chatRoomMembershipsRepository,
+ useFactory: (db: DataSource) => db.getRepository(MiChatRoomMembership).extend(miRepository as MiRepository<MiChatRoomMembership>),
+ inject: [DI.db],
+};
+
+const $chatRoomInvitationsRepository: Provider = {
+ provide: DI.chatRoomInvitationsRepository,
+ useFactory: (db: DataSource) => db.getRepository(MiChatRoomInvitation).extend(miRepository as MiRepository<MiChatRoomInvitation>),
+ inject: [DI.db],
+};
+
+const $chatApprovalsRepository: Provider = {
+ provide: DI.chatApprovalsRepository,
+ useFactory: (db: DataSource) => db.getRepository(MiChatApproval).extend(miRepository as MiRepository<MiChatApproval>),
+ inject: [DI.db],
+};
+
const $bubbleGameRecordsRepository: Provider = {
provide: DI.bubbleGameRecordsRepository,
useFactory: (db: DataSource) => db.getRepository(MiBubbleGameRecord).extend(miRepository as MiRepository<MiBubbleGameRecord>),
@@ -514,7 +542,6 @@ const $reversiGamesRepository: Provider = {
$noteFavoritesRepository,
$noteThreadMutingsRepository,
$noteReactionsRepository,
- $noteUnreadsRepository,
$pollsRepository,
$pollVotesRepository,
$userProfilesRepository,
@@ -573,6 +600,11 @@ const $reversiGamesRepository: Provider = {
$flashsRepository,
$flashLikesRepository,
$userMemosRepository,
+ $chatMessagesRepository,
+ $chatRoomsRepository,
+ $chatRoomMembershipsRepository,
+ $chatRoomInvitationsRepository,
+ $chatApprovalsRepository,
$bubbleGameRecordsRepository,
$reversiGamesRepository,
],
@@ -586,7 +618,6 @@ const $reversiGamesRepository: Provider = {
$noteFavoritesRepository,
$noteThreadMutingsRepository,
$noteReactionsRepository,
- $noteUnreadsRepository,
$pollsRepository,
$pollVotesRepository,
$userProfilesRepository,
@@ -645,6 +676,11 @@ const $reversiGamesRepository: Provider = {
$flashsRepository,
$flashLikesRepository,
$userMemosRepository,
+ $chatMessagesRepository,
+ $chatRoomsRepository,
+ $chatRoomMembershipsRepository,
+ $chatRoomInvitationsRepository,
+ $chatApprovalsRepository,
$bubbleGameRecordsRepository,
$reversiGamesRepository,
],
diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts
index 630240efde..bc652cea62 100644
--- a/packages/backend/src/models/User.ts
+++ b/packages/backend/src/models/User.ts
@@ -225,6 +225,17 @@ export class MiUser {
})
public emojis: string[];
+ // チャットを許可する相手
+ // everyone: 誰からでも
+ // followers: フォロワーのみ
+ // following: フォローしているユーザーのみ
+ // mutual: 相互フォローのみ
+ // none: 誰からも受け付けない
+ @Column('varchar', {
+ length: 128, default: 'mutual',
+ })
+ public chatScope: 'everyone' | 'followers' | 'following' | 'mutual' | 'none';
+
@Index()
@Column('varchar', {
length: 128, nullable: true,
diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts
index fa15760c00..e852b302f3 100644
--- a/packages/backend/src/models/_.ts
+++ b/packages/backend/src/models/_.ts
@@ -3,13 +3,10 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { FindOneOptions, InsertQueryBuilder, ObjectLiteral, Repository, SelectQueryBuilder, TypeORMError } from 'typeorm';
-import { DriverUtils } from 'typeorm/driver/DriverUtils.js';
+import { FindOneOptions, InsertQueryBuilder, ObjectLiteral, Repository, SelectQueryBuilder } from 'typeorm';
import { RelationCountLoader } from 'typeorm/query-builder/relation-count/RelationCountLoader.js';
import { RelationIdLoader } from 'typeorm/query-builder/relation-id/RelationIdLoader.js';
import { RawSqlResultsToEntityTransformer } from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js';
-import { ObjectUtils } from 'typeorm/util/ObjectUtils.js';
-import { OrmUtils } from 'typeorm/util/OrmUtils.js';
import { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js';
import { MiAccessToken } from '@/models/AccessToken.js';
@@ -43,7 +40,6 @@ import { MiNote } from '@/models/Note.js';
import { MiNoteFavorite } from '@/models/NoteFavorite.js';
import { MiNoteReaction } from '@/models/NoteReaction.js';
import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js';
-import { MiNoteUnread } from '@/models/NoteUnread.js';
import { MiPage } from '@/models/Page.js';
import { MiPageLike } from '@/models/PageLike.js';
import { MiPasswordResetRequest } from '@/models/PasswordResetRequest.js';
@@ -78,6 +74,11 @@ import { MiRoleAssignment } from '@/models/RoleAssignment.js';
import { MiFlash } from '@/models/Flash.js';
import { MiFlashLike } from '@/models/FlashLike.js';
import { MiUserListFavorite } from '@/models/UserListFavorite.js';
+import { MiChatMessage } from '@/models/ChatMessage.js';
+import { MiChatRoom } from '@/models/ChatRoom.js';
+import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js';
+import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js';
+import { MiChatApproval } from '@/models/ChatApproval.js';
import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
import { MiReversiGame } from '@/models/ReversiGame.js';
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js';
@@ -159,7 +160,6 @@ export {
MiNoteFavorite,
MiNoteReaction,
MiNoteThreadMuting,
- MiNoteUnread,
MiPage,
MiPageLike,
MiPasswordResetRequest,
@@ -194,6 +194,11 @@ export {
MiFlash,
MiFlashLike,
MiUserMemo,
+ MiChatMessage,
+ MiChatRoom,
+ MiChatRoomMembership,
+ MiChatRoomInvitation,
+ MiChatApproval,
MiBubbleGameRecord,
MiReversiGame,
};
@@ -231,7 +236,6 @@ export type NotesRepository = Repository<MiNote> & MiRepository<MiNote>;
export type NoteFavoritesRepository = Repository<MiNoteFavorite> & MiRepository<MiNoteFavorite>;
export type NoteReactionsRepository = Repository<MiNoteReaction> & MiRepository<MiNoteReaction>;
export type NoteThreadMutingsRepository = Repository<MiNoteThreadMuting> & MiRepository<MiNoteThreadMuting>;
-export type NoteUnreadsRepository = Repository<MiNoteUnread> & MiRepository<MiNoteUnread>;
export type PagesRepository = Repository<MiPage> & MiRepository<MiPage>;
export type PageLikesRepository = Repository<MiPageLike> & MiRepository<MiPageLike>;
export type PasswordResetRequestsRepository = Repository<MiPasswordResetRequest> & MiRepository<MiPasswordResetRequest>;
@@ -266,5 +270,10 @@ export type RoleAssignmentsRepository = Repository<MiRoleAssignment> & MiReposit
export type FlashsRepository = Repository<MiFlash> & MiRepository<MiFlash>;
export type FlashLikesRepository = Repository<MiFlashLike> & MiRepository<MiFlashLike>;
export type UserMemoRepository = Repository<MiUserMemo> & MiRepository<MiUserMemo>;
+export type ChatMessagesRepository = Repository<MiChatMessage> & MiRepository<MiChatMessage>;
+export type ChatRoomsRepository = Repository<MiChatRoom> & MiRepository<MiChatRoom>;
+export type ChatRoomMembershipsRepository = Repository<MiChatRoomMembership> & MiRepository<MiChatRoomMembership>;
+export type ChatRoomInvitationsRepository = Repository<MiChatRoomInvitation> & MiRepository<MiChatRoomInvitation>;
+export type ChatApprovalsRepository = Repository<MiChatApproval> & MiRepository<MiChatApproval>;
export type BubbleGameRecordsRepository = Repository<MiBubbleGameRecord> & MiRepository<MiBubbleGameRecord>;
export type ReversiGamesRepository = Repository<MiReversiGame> & MiRepository<MiReversiGame>;
diff --git a/packages/backend/src/models/json-schema/chat-message.ts b/packages/backend/src/models/json-schema/chat-message.ts
new file mode 100644
index 0000000000..44b7298702
--- /dev/null
+++ b/packages/backend/src/models/json-schema/chat-message.ts
@@ -0,0 +1,146 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export const packedChatMessageSchema = {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ createdAt: {
+ type: 'string',
+ format: 'date-time',
+ optional: false, nullable: false,
+ },
+ fromUserId: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ fromUser: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'UserLite',
+ },
+ toUserId: {
+ type: 'string',
+ optional: true, nullable: true,
+ },
+ toUser: {
+ type: 'object',
+ optional: true, nullable: true,
+ ref: 'UserLite',
+ },
+ toRoomId: {
+ type: 'string',
+ optional: true, nullable: true,
+ },
+ toRoom: {
+ type: 'object',
+ optional: true, nullable: true,
+ ref: 'ChatRoom',
+ },
+ text: {
+ type: 'string',
+ optional: true, nullable: true,
+ },
+ fileId: {
+ type: 'string',
+ optional: true, nullable: true,
+ },
+ file: {
+ type: 'object',
+ optional: true, nullable: true,
+ ref: 'DriveFile',
+ },
+ isRead: {
+ type: 'boolean',
+ optional: true, nullable: false,
+ },
+ reactions: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ properties: {
+ reaction: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ user: {
+ type: 'object',
+ optional: true, nullable: true,
+ ref: 'UserLite',
+ },
+ },
+ },
+ },
+ },
+} as const;
+
+export const packedChatMessageLiteSchema = {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ createdAt: {
+ type: 'string',
+ format: 'date-time',
+ optional: false, nullable: false,
+ },
+ fromUserId: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ fromUser: {
+ type: 'object',
+ optional: true, nullable: false,
+ ref: 'UserLite',
+ },
+ toUserId: {
+ type: 'string',
+ optional: true, nullable: true,
+ },
+ toRoomId: {
+ type: 'string',
+ optional: true, nullable: true,
+ },
+ text: {
+ type: 'string',
+ optional: true, nullable: true,
+ },
+ fileId: {
+ type: 'string',
+ optional: true, nullable: true,
+ },
+ file: {
+ type: 'object',
+ optional: true, nullable: true,
+ ref: 'DriveFile',
+ },
+ reactions: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ properties: {
+ reaction: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ user: {
+ type: 'object',
+ optional: true, nullable: true,
+ ref: 'UserLite',
+ },
+ },
+ },
+ },
+ },
+} as const;
diff --git a/packages/backend/src/models/json-schema/chat-room-invitation.ts b/packages/backend/src/models/json-schema/chat-room-invitation.ts
new file mode 100644
index 0000000000..204c959b2c
--- /dev/null
+++ b/packages/backend/src/models/json-schema/chat-room-invitation.ts
@@ -0,0 +1,37 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export const packedChatRoomInvitationSchema = {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ createdAt: {
+ type: 'string',
+ format: 'date-time',
+ optional: false, nullable: false,
+ },
+ userId: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ user: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'UserLite',
+ },
+ roomId: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ room: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatRoom',
+ },
+ },
+} as const;
diff --git a/packages/backend/src/models/json-schema/chat-room-membership.ts b/packages/backend/src/models/json-schema/chat-room-membership.ts
new file mode 100644
index 0000000000..adb73f9dde
--- /dev/null
+++ b/packages/backend/src/models/json-schema/chat-room-membership.ts
@@ -0,0 +1,37 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export const packedChatRoomMembershipSchema = {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ createdAt: {
+ type: 'string',
+ format: 'date-time',
+ optional: false, nullable: false,
+ },
+ userId: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ user: {
+ type: 'object',
+ optional: true, nullable: false,
+ ref: 'UserLite',
+ },
+ roomId: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ room: {
+ type: 'object',
+ optional: true, nullable: false,
+ ref: 'ChatRoom',
+ },
+ },
+} as const;
diff --git a/packages/backend/src/models/json-schema/chat-room.ts b/packages/backend/src/models/json-schema/chat-room.ts
new file mode 100644
index 0000000000..e97556e378
--- /dev/null
+++ b/packages/backend/src/models/json-schema/chat-room.ts
@@ -0,0 +1,40 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export const packedChatRoomSchema = {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ createdAt: {
+ type: 'string',
+ format: 'date-time',
+ optional: false, nullable: false,
+ },
+ ownerId: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ owner: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'UserLite',
+ },
+ name: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ description: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ isMuted: {
+ type: 'boolean',
+ optional: true, nullable: false,
+ },
+ },
+} as const;
diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts
index 3537de94c8..1685a806c9 100644
--- a/packages/backend/src/models/json-schema/role.ts
+++ b/packages/backend/src/models/json-schema/role.ts
@@ -292,6 +292,10 @@ export const packedRolePoliciesSchema = {
type: 'boolean',
optional: false, nullable: false,
},
+ canChat: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
},
} as const;
diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts
index 38631f907d..b10430782b 100644
--- a/packages/backend/src/models/json-schema/user.ts
+++ b/packages/backend/src/models/json-schema/user.ts
@@ -358,6 +358,11 @@ export const packedUserDetailedNotMeOnlySchema = {
nullable: false, optional: false,
enum: ['public', 'followers', 'private'],
},
+ chatScope: {
+ type: 'string',
+ nullable: false, optional: false,
+ enum: ['everyone', 'following', 'followers', 'mutual', 'none'],
+ },
roles: {
type: 'array',
nullable: false, optional: false,
@@ -540,6 +545,10 @@ export const packedMeDetailedOnlySchema = {
type: 'boolean',
nullable: false, optional: false,
},
+ hasUnreadChatMessages: {
+ type: 'boolean',
+ nullable: false, optional: false,
+ },
hasUnreadNotification: {
type: 'boolean',
nullable: false, optional: false,
diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts
index 043332d4b5..4694e7003d 100644
--- a/packages/backend/src/postgres.ts
+++ b/packages/backend/src/postgres.ts
@@ -8,6 +8,9 @@ import pg from 'pg';
import { DataSource, Logger } from 'typeorm';
import * as highlight from 'cli-highlight';
import { entities as charts } from '@/core/chart/entities.js';
+import { Config } from '@/config.js';
+import MisskeyLogger from '@/logger.js';
+import { bindThis } from '@/decorators.js';
import { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js';
@@ -42,7 +45,6 @@ import { MiNote } from '@/models/Note.js';
import { MiNoteFavorite } from '@/models/NoteFavorite.js';
import { MiNoteReaction } from '@/models/NoteReaction.js';
import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js';
-import { MiNoteUnread } from '@/models/NoteUnread.js';
import { MiPage } from '@/models/Page.js';
import { MiPageLike } from '@/models/PageLike.js';
import { MiPasswordResetRequest } from '@/models/PasswordResetRequest.js';
@@ -76,13 +78,14 @@ import { MiRoleAssignment } from '@/models/RoleAssignment.js';
import { MiFlash } from '@/models/Flash.js';
import { MiFlashLike } from '@/models/FlashLike.js';
import { MiUserMemo } from '@/models/UserMemo.js';
+import { MiChatMessage } from '@/models/ChatMessage.js';
+import { MiChatRoom } from '@/models/ChatRoom.js';
+import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js';
+import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js';
import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
import { MiReversiGame } from '@/models/ReversiGame.js';
-
-import { Config } from '@/config.js';
-import MisskeyLogger from '@/logger.js';
-import { bindThis } from '@/decorators.js';
-import { MiSystemAccount } from './models/SystemAccount.js';
+import { MiChatApproval } from '@/models/ChatApproval.js';
+import { MiSystemAccount } from '@/models/SystemAccount.js';
pg.types.setTypeParser(20, Number);
@@ -195,7 +198,6 @@ export const entities = [
MiNoteFavorite,
MiNoteReaction,
MiNoteThreadMuting,
- MiNoteUnread,
MiPage,
MiPageLike,
MiGalleryPost,
@@ -236,6 +238,11 @@ export const entities = [
MiFlash,
MiFlashLike,
MiUserMemo,
+ MiChatMessage,
+ MiChatRoom,
+ MiChatRoomMembership,
+ MiChatRoomInvitation,
+ MiChatApproval,
MiBubbleGameRecord,
MiReversiGame,
...charts,
diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts
index 3ab0b815f2..0223650329 100644
--- a/packages/backend/src/server/ServerModule.ts
+++ b/packages/backend/src/server/ServerModule.ts
@@ -44,6 +44,8 @@ import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js';
import { ServerStatsChannelService } from './api/stream/channels/server-stats.js';
import { UserListChannelService } from './api/stream/channels/user-list.js';
import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
+import { ChatUserChannelService } from './api/stream/channels/chat-user.js';
+import { ChatRoomChannelService } from './api/stream/channels/chat-room.js';
import { ReversiChannelService } from './api/stream/channels/reversi.js';
import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js';
import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js';
@@ -84,6 +86,8 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
GlobalTimelineChannelService,
HashtagChannelService,
RoleTimelineChannelService,
+ ChatUserChannelService,
+ ChatRoomChannelService,
ReversiChannelService,
ReversiGameChannelService,
HomeTimelineChannelService,
diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts
index b8f448477b..2a4e1fc574 100644
--- a/packages/backend/src/server/api/StreamingApiServerService.ts
+++ b/packages/backend/src/server/api/StreamingApiServerService.ts
@@ -9,7 +9,6 @@ import * as Redis from 'ioredis';
import * as WebSocket from 'ws';
import { DI } from '@/di-symbols.js';
import type { UsersRepository, MiAccessToken } from '@/models/_.js';
-import { NoteReadService } from '@/core/NoteReadService.js';
import { NotificationService } from '@/core/NotificationService.js';
import { bindThis } from '@/decorators.js';
import { CacheService } from '@/core/CacheService.js';
@@ -35,7 +34,6 @@ export class StreamingApiServerService {
private usersRepository: UsersRepository,
private cacheService: CacheService,
- private noteReadService: NoteReadService,
private authenticateService: AuthenticateService,
private channelsService: ChannelsService,
private notificationService: NotificationService,
@@ -96,7 +94,6 @@ export class StreamingApiServerService {
const stream = new MainStreamConnection(
this.channelsService,
- this.noteReadService,
this.notificationService,
this.cacheService,
this.channelFollowingService,
diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts
index 560d3f6587..9bb29e138b 100644
--- a/packages/backend/src/server/api/endpoint-list.ts
+++ b/packages/backend/src/server/api/endpoint-list.ts
@@ -263,7 +263,6 @@ export * as 'i/notifications-grouped' from './endpoints/i/notifications-grouped.
export * as 'i/page-likes' from './endpoints/i/page-likes.js';
export * as 'i/pages' from './endpoints/i/pages.js';
export * as 'i/pin' from './endpoints/i/pin.js';
-export * as 'i/read-all-unread-notes' from './endpoints/i/read-all-unread-notes.js';
export * as 'i/read-announcement' from './endpoints/i/read-announcement.js';
export * as 'i/regenerate-token' from './endpoints/i/regenerate-token.js';
export * as 'i/registry/get' from './endpoints/i/registry/get.js';
@@ -397,4 +396,26 @@ export * as 'users/search' from './endpoints/users/search.js';
export * as 'users/search-by-username-and-host' from './endpoints/users/search-by-username-and-host.js';
export * as 'users/show' from './endpoints/users/show.js';
export * as 'users/update-memo' from './endpoints/users/update-memo.js';
+export * as 'chat/messages/create-to-user' from './endpoints/chat/messages/create-to-user.js';
+export * as 'chat/messages/create-to-room' from './endpoints/chat/messages/create-to-room.js';
+export * as 'chat/messages/delete' from './endpoints/chat/messages/delete.js';
+export * as 'chat/messages/show' from './endpoints/chat/messages/show.js';
+export * as 'chat/messages/react' from './endpoints/chat/messages/react.js';
+export * as 'chat/messages/user-timeline' from './endpoints/chat/messages/user-timeline.js';
+export * as 'chat/messages/room-timeline' from './endpoints/chat/messages/room-timeline.js';
+export * as 'chat/messages/search' from './endpoints/chat/messages/search.js';
+export * as 'chat/rooms/create' from './endpoints/chat/rooms/create.js';
+export * as 'chat/rooms/delete' from './endpoints/chat/rooms/delete.js';
+export * as 'chat/rooms/join' from './endpoints/chat/rooms/join.js';
+export * as 'chat/rooms/leave' from './endpoints/chat/rooms/leave.js';
+export * as 'chat/rooms/mute' from './endpoints/chat/rooms/mute.js';
+export * as 'chat/rooms/show' from './endpoints/chat/rooms/show.js';
+export * as 'chat/rooms/owned' from './endpoints/chat/rooms/owned.js';
+export * as 'chat/rooms/joining' from './endpoints/chat/rooms/joining.js';
+export * as 'chat/rooms/update' from './endpoints/chat/rooms/update.js';
+export * as 'chat/rooms/members' from './endpoints/chat/rooms/members.js';
+export * as 'chat/rooms/invitations/create' from './endpoints/chat/rooms/invitations/create.js';
+export * as 'chat/rooms/invitations/ignore' from './endpoints/chat/rooms/invitations/ignore.js';
+export * as 'chat/rooms/invitations/inbox' from './endpoints/chat/rooms/invitations/inbox.js';
+export * as 'chat/history' from './endpoints/chat/history.js';
export * as 'v2/admin/emoji/list' from './endpoints/v2/admin/emoji/list.js';
diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts
index f4dfe1ecc4..727697ea14 100644
--- a/packages/backend/src/server/api/endpoints/antennas/notes.ts
+++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts
@@ -8,7 +8,6 @@ import * as Redis from 'ioredis';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { NotesRepository, AntennasRepository } from '@/models/_.js';
import { QueryService } from '@/core/QueryService.js';
-import { NoteReadService } from '@/core/NoteReadService.js';
import { DI } from '@/di-symbols.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { IdService } from '@/core/IdService.js';
@@ -59,9 +58,6 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
- @Inject(DI.redisForTimelines)
- private redisForTimelines: Redis.Redis,
-
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@@ -71,7 +67,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private idService: IdService,
private noteEntityService: NoteEntityService,
private queryService: QueryService,
- private noteReadService: NoteReadService,
private fanoutTimelineService: FanoutTimelineService,
private globalEventService: GlobalEventService,
) {
@@ -124,8 +119,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
notes.sort((a, b) => a.id > b.id ? -1 : 1);
}
- this.noteReadService.read(me.id, notes);
-
return await this.noteEntityService.packMany(notes, me);
});
}
diff --git a/packages/backend/src/server/api/endpoints/chat/history.ts b/packages/backend/src/server/api/endpoints/chat/history.ts
new file mode 100644
index 0000000000..7553a751e0
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/history.ts
@@ -0,0 +1,73 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'read:chat',
+
+ res: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatMessage',
+ },
+ },
+
+ errors: {
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+ room: { type: 'boolean', default: false },
+ },
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatEntityService: ChatEntityService,
+ private chatService: ChatService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const history = ps.room ? await this.chatService.roomHistory(me.id, ps.limit) : await this.chatService.userHistory(me.id, ps.limit);
+
+ const packedMessages = await this.chatEntityService.packMessagesDetailed(history, me);
+
+ if (ps.room) {
+ const roomIds = history.map(m => m.toRoomId!);
+ const readStateMap = await this.chatService.getRoomReadStateMap(me.id, roomIds);
+
+ for (const message of packedMessages) {
+ message.isRead = readStateMap[message.toRoomId!] ?? false;
+ }
+ } else {
+ const otherIds = history.map(m => m.fromUserId === me.id ? m.toUserId! : m.fromUserId!);
+ const readStateMap = await this.chatService.getUserReadStateMap(me.id, otherIds);
+
+ for (const message of packedMessages) {
+ const otherId = message.fromUserId === me.id ? message.toUserId! : message.fromUserId!;
+ message.isRead = readStateMap[otherId] ?? false;
+ }
+ }
+
+ return packedMessages;
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts b/packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts
new file mode 100644
index 0000000000..1f334d5750
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts
@@ -0,0 +1,105 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import ms from 'ms';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { GetterService } from '@/server/api/GetterService.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '@/server/api/error.js';
+import { ChatService } from '@/core/ChatService.js';
+import type { DriveFilesRepository, MiUser } from '@/models/_.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+ requiredRolePolicy: 'canChat',
+
+ prohibitMoved: true,
+
+ kind: 'write:chat',
+
+ limit: {
+ duration: ms('1hour'),
+ max: 500,
+ },
+
+ res: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatMessageLite',
+ },
+
+ errors: {
+ noSuchRoom: {
+ message: 'No such room.',
+ code: 'NO_SUCH_ROOM',
+ id: '8098520d-2da5-4e8f-8ee1-df78b55a4ec6',
+ },
+
+ noSuchFile: {
+ message: 'No such file.',
+ code: 'NO_SUCH_FILE',
+ id: 'b6accbd3-1d7b-4d9f-bdb7-eb185bac06db',
+ },
+
+ contentRequired: {
+ message: 'Content required. You need to set text or fileId.',
+ code: 'CONTENT_REQUIRED',
+ id: '340517b7-6d04-42c0-bac1-37ee804e3594',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ text: { type: 'string', nullable: true, maxLength: 2000 },
+ fileId: { type: 'string', format: 'misskey:id' },
+ toRoomId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['toRoomId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ @Inject(DI.driveFilesRepository)
+ private driveFilesRepository: DriveFilesRepository,
+
+ private getterService: GetterService,
+ private chatService: ChatService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const room = await this.chatService.findRoomById(ps.toRoomId);
+ if (room == null) {
+ throw new ApiError(meta.errors.noSuchRoom);
+ }
+
+ let file = null;
+ if (ps.fileId != null) {
+ file = await this.driveFilesRepository.findOneBy({
+ id: ps.fileId,
+ userId: me.id,
+ });
+
+ if (file == null) {
+ throw new ApiError(meta.errors.noSuchFile);
+ }
+ }
+
+ // テキストが無いかつ添付ファイルも無かったらエラー
+ if (ps.text == null && file == null) {
+ throw new ApiError(meta.errors.contentRequired);
+ }
+
+ return await this.chatService.createMessageToRoom(me, room, {
+ text: ps.text,
+ file: file,
+ });
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts b/packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts
new file mode 100644
index 0000000000..6b77a026fb
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts
@@ -0,0 +1,122 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import ms from 'ms';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { GetterService } from '@/server/api/GetterService.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '@/server/api/error.js';
+import { ChatService } from '@/core/ChatService.js';
+import type { DriveFilesRepository, MiUser } from '@/models/_.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+ requiredRolePolicy: 'canChat',
+
+ prohibitMoved: true,
+
+ kind: 'write:chat',
+
+ limit: {
+ duration: ms('1hour'),
+ max: 500,
+ },
+
+ res: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatMessageLite',
+ },
+
+ errors: {
+ recipientIsYourself: {
+ message: 'You can not send a message to yourself.',
+ code: 'RECIPIENT_IS_YOURSELF',
+ id: '17e2ba79-e22a-4cbc-bf91-d327643f4a7e',
+ },
+
+ noSuchUser: {
+ message: 'No such user.',
+ code: 'NO_SUCH_USER',
+ id: '11795c64-40ea-4198-b06e-3c873ed9039d',
+ },
+
+ noSuchFile: {
+ message: 'No such file.',
+ code: 'NO_SUCH_FILE',
+ id: '4372b8e2-185d-4146-8749-2f68864a3e5f',
+ },
+
+ contentRequired: {
+ message: 'Content required. You need to set text or fileId.',
+ code: 'CONTENT_REQUIRED',
+ id: '25587321-b0e6-449c-9239-f8925092942c',
+ },
+
+ youHaveBeenBlocked: {
+ message: 'You cannot send a message because you have been blocked by this user.',
+ code: 'YOU_HAVE_BEEN_BLOCKED',
+ id: 'c15a5199-7422-4968-941a-2a462c478f7d',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ text: { type: 'string', nullable: true, maxLength: 2000 },
+ fileId: { type: 'string', format: 'misskey:id' },
+ toUserId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['toUserId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ @Inject(DI.driveFilesRepository)
+ private driveFilesRepository: DriveFilesRepository,
+
+ private getterService: GetterService,
+ private chatService: ChatService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ let file = null;
+ if (ps.fileId != null) {
+ file = await this.driveFilesRepository.findOneBy({
+ id: ps.fileId,
+ userId: me.id,
+ });
+
+ if (file == null) {
+ throw new ApiError(meta.errors.noSuchFile);
+ }
+ }
+
+ // テキストが無いかつ添付ファイルも無かったらエラー
+ if (ps.text == null && file == null) {
+ throw new ApiError(meta.errors.contentRequired);
+ }
+
+ // Myself
+ if (ps.toUserId === me.id) {
+ throw new ApiError(meta.errors.recipientIsYourself);
+ }
+
+ const toUser = await this.getterService.getUser(ps.toUserId).catch(err => {
+ if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
+ throw err;
+ });
+
+ return await this.chatService.createMessageToUser(me, toUser, {
+ text: ps.text,
+ file: file,
+ });
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/messages/delete.ts b/packages/backend/src/server/api/endpoints/chat/messages/delete.ts
new file mode 100644
index 0000000000..959599ddcf
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/messages/delete.ts
@@ -0,0 +1,52 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'write:chat',
+
+ res: {
+ },
+
+ errors: {
+ noSuchMessage: {
+ message: 'No such message.',
+ code: 'NO_SUCH_MESSAGE',
+ id: '36b67f0e-66a6-414b-83df-992a55294f17',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ messageId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['messageId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatService: ChatService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const message = await this.chatService.findMyMessageById(me.id, ps.messageId);
+ if (message == null) {
+ throw new ApiError(meta.errors.noSuchMessage);
+ }
+ await this.chatService.deleteMessage(message);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/messages/react.ts b/packages/backend/src/server/api/endpoints/chat/messages/react.ts
new file mode 100644
index 0000000000..561e36ed19
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/messages/react.ts
@@ -0,0 +1,49 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'write:chat',
+
+ res: {
+ },
+
+ errors: {
+ noSuchMessage: {
+ message: 'No such message.',
+ code: 'NO_SUCH_MESSAGE',
+ id: '9b5839b9-0ba0-4351-8c35-37082093d200',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ messageId: { type: 'string', format: 'misskey:id' },
+ reaction: { type: 'string' },
+ },
+ required: ['messageId', 'reaction'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatService: ChatService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ await this.chatService.react(ps.messageId, me.id, ps.reaction);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts b/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts
new file mode 100644
index 0000000000..ccc0030403
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts
@@ -0,0 +1,73 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'read:chat',
+
+ res: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatMessageLite',
+ },
+ },
+
+ errors: {
+ noSuchRoom: {
+ message: 'No such room.',
+ code: 'NO_SUCH_ROOM',
+ id: 'c4d9f88c-9270-4632-b032-6ed8cee36f7f',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+ sinceId: { type: 'string', format: 'misskey:id' },
+ untilId: { type: 'string', format: 'misskey:id' },
+ roomId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['roomId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatEntityService: ChatEntityService,
+ private chatService: ChatService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const room = await this.chatService.findRoomById(ps.roomId);
+ if (room == null) {
+ throw new ApiError(meta.errors.noSuchRoom);
+ }
+
+ if (!(await this.chatService.isRoomMember(room, me.id))) {
+ throw new ApiError(meta.errors.noSuchRoom);
+ }
+
+ const messages = await this.chatService.roomTimeline(room.id, ps.limit, ps.sinceId, ps.untilId);
+
+ this.chatService.readRoomChatMessage(me.id, room.id);
+
+ return await this.chatEntityService.packMessagesLiteForRoom(messages);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/messages/search.ts b/packages/backend/src/server/api/endpoints/chat/messages/search.ts
new file mode 100644
index 0000000000..4c989e5ca9
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/messages/search.ts
@@ -0,0 +1,76 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'read:chat',
+
+ res: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatMessage',
+ },
+ },
+
+ errors: {
+ noSuchRoom: {
+ message: 'No such room.',
+ code: 'NO_SUCH_ROOM',
+ id: '460b3669-81b0-4dc9-a997-44442141bf83',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ query: { type: 'string', minLength: 1, maxLength: 256 },
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+ userId: { type: 'string', format: 'misskey:id', nullable: true },
+ roomId: { type: 'string', format: 'misskey:id', nullable: true },
+ },
+ required: ['query'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatEntityService: ChatEntityService,
+ private chatService: ChatService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ if (ps.roomId != null) {
+ const room = await this.chatService.findRoomById(ps.roomId);
+ if (room == null) {
+ throw new ApiError(meta.errors.noSuchRoom);
+ }
+
+ if (!(await this.chatService.isRoomMember(room, me.id))) {
+ throw new ApiError(meta.errors.noSuchRoom);
+ }
+ }
+
+ const messages = await this.chatService.searchMessages(me.id, ps.query, ps.limit, {
+ userId: ps.userId,
+ roomId: ps.roomId,
+ });
+
+ return await this.chatEntityService.packMessagesDetailed(messages, me);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/messages/show.ts b/packages/backend/src/server/api/endpoints/chat/messages/show.ts
new file mode 100644
index 0000000000..371f7a7071
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/messages/show.ts
@@ -0,0 +1,63 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { GetterService } from '@/server/api/GetterService.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+import { ApiError } from '@/server/api/error.js';
+import { RoleService } from '@/core/RoleService.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'read:chat',
+
+ res: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatMessage',
+ },
+
+ errors: {
+ noSuchMessage: {
+ message: 'No such message.',
+ code: 'NO_SUCH_MESSAGE',
+ id: '3710865b-1848-4da9-8d61-cfed15510b93',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ messageId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['messageId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatService: ChatService,
+ private roleService: RoleService,
+ private chatEntityService: ChatEntityService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const message = await this.chatService.findMessageById(ps.messageId);
+ if (message == null) {
+ throw new ApiError(meta.errors.noSuchMessage);
+ }
+ if (message.fromUserId !== me.id && message.toUserId !== me.id && !(await this.roleService.isModerator(me))) {
+ throw new ApiError(meta.errors.noSuchMessage);
+ }
+ return this.chatEntityService.packMessageDetailed(message, me);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts b/packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts
new file mode 100644
index 0000000000..9d308d79b0
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts
@@ -0,0 +1,71 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { GetterService } from '@/server/api/GetterService.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'read:chat',
+
+ res: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatMessageLite',
+ },
+ },
+
+ errors: {
+ noSuchUser: {
+ message: 'No such user.',
+ code: 'NO_SUCH_USER',
+ id: '11795c64-40ea-4198-b06e-3c873ed9039d',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+ sinceId: { type: 'string', format: 'misskey:id' },
+ untilId: { type: 'string', format: 'misskey:id' },
+ userId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['userId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatEntityService: ChatEntityService,
+ private chatService: ChatService,
+ private getterService: GetterService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const other = await this.getterService.getUser(ps.userId).catch(err => {
+ if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
+ throw err;
+ });
+
+ const messages = await this.chatService.userTimeline(me.id, other.id, ps.limit, ps.sinceId, ps.untilId);
+
+ this.chatService.readUserChatMessage(me.id, other.id);
+
+ return await this.chatEntityService.packMessagesLiteFor1on1(messages);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/create.ts b/packages/backend/src/server/api/endpoints/chat/rooms/create.ts
new file mode 100644
index 0000000000..fa4cc8ceb4
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/create.ts
@@ -0,0 +1,62 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import ms from 'ms';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '@/server/api/error.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+ requiredRolePolicy: 'canChat',
+
+ prohibitMoved: true,
+
+ kind: 'write:chat',
+
+ limit: {
+ duration: ms('1day'),
+ max: 10,
+ },
+
+ res: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatRoom',
+ },
+
+ errors: {
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ name: { type: 'string', maxLength: 256 },
+ description: { type: 'string', maxLength: 1024 },
+ },
+ required: ['name'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatService: ChatService,
+ private chatEntityService: ChatEntityService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const room = await this.chatService.createRoom(me, {
+ name: ps.name,
+ description: ps.description ?? '',
+ });
+ return await this.chatEntityService.packRoom(room);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts b/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts
new file mode 100644
index 0000000000..2ef0a778f1
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts
@@ -0,0 +1,52 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'write:chat',
+
+ res: {
+ },
+
+ errors: {
+ noSuchRoom: {
+ message: 'No such room.',
+ code: 'NO_SUCH_ROOM',
+ id: 'd4e3753d-97bf-4a19-ab8e-21080fbc0f4b',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ roomId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['roomId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatService: ChatService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const room = await this.chatService.findMyRoomById(me.id, ps.roomId);
+ if (room == null) {
+ throw new ApiError(meta.errors.noSuchRoom);
+ }
+ await this.chatService.deleteRoom(room);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts
new file mode 100644
index 0000000000..5da4a1a772
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts
@@ -0,0 +1,68 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import ms from 'ms';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '@/server/api/error.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+ requiredRolePolicy: 'canChat',
+
+ prohibitMoved: true,
+
+ kind: 'write:chat',
+
+ limit: {
+ duration: ms('1day'),
+ max: 50,
+ },
+
+ res: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatRoomInvitation',
+ },
+
+ errors: {
+ noSuchRoom: {
+ message: 'No such room.',
+ code: 'NO_SUCH_ROOM',
+ id: '916f9507-49ba-4e90-b57f-1fd4deaa47a5',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ roomId: { type: 'string', format: 'misskey:id' },
+ userId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['roomId', 'userId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatService: ChatService,
+ private chatEntityService: ChatEntityService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const room = await this.chatService.findMyRoomById(me.id, ps.roomId);
+ if (room == null) {
+ throw new ApiError(meta.errors.noSuchRoom);
+ }
+ const invitation = await this.chatService.createRoomInvitation(me.id, room.id, ps.userId);
+ return await this.chatEntityService.packRoomInvitation(invitation, me);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/ignore.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/ignore.ts
new file mode 100644
index 0000000000..8c017f7d01
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/ignore.ts
@@ -0,0 +1,48 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'write:chat',
+
+ res: {
+ },
+
+ errors: {
+ noSuchRoom: {
+ message: 'No such room.',
+ code: 'NO_SUCH_ROOM',
+ id: '5130557e-5a11-4cfb-9cc5-fe60cda5de0d',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ roomId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['roomId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatService: ChatService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ await this.chatService.ignoreRoomInvitation(me.id, ps.roomId);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts
new file mode 100644
index 0000000000..07337480fc
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts
@@ -0,0 +1,54 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'read:chat',
+
+ res: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatRoomInvitation',
+ },
+ },
+
+ errors: {
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
+ sinceId: { type: 'string', format: 'misskey:id' },
+ untilId: { type: 'string', format: 'misskey:id' },
+ },
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatEntityService: ChatEntityService,
+ private chatService: ChatService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const invitations = await this.chatService.getReceivedRoomInvitationsWithPagination(me.id, ps.limit, ps.sinceId, ps.untilId);
+ return this.chatEntityService.packRoomInvitations(invitations, me);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/join.ts b/packages/backend/src/server/api/endpoints/chat/rooms/join.ts
new file mode 100644
index 0000000000..dbd4d1ea5a
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/join.ts
@@ -0,0 +1,48 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'write:chat',
+
+ res: {
+ },
+
+ errors: {
+ noSuchRoom: {
+ message: 'No such room.',
+ code: 'NO_SUCH_ROOM',
+ id: '84416476-5ce8-4a2c-b568-9569f1b10733',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ roomId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['roomId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatService: ChatService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ await this.chatService.joinToRoom(me.id, ps.roomId);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/joining.ts b/packages/backend/src/server/api/endpoints/chat/rooms/joining.ts
new file mode 100644
index 0000000000..c4c6253236
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/joining.ts
@@ -0,0 +1,58 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'read:chat',
+
+ res: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatRoomMembership',
+ },
+ },
+
+ errors: {
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
+ sinceId: { type: 'string', format: 'misskey:id' },
+ untilId: { type: 'string', format: 'misskey:id' },
+ },
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatService: ChatService,
+ private chatEntityService: ChatEntityService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const memberships = await this.chatService.getMyMemberships(me.id, ps.limit, ps.sinceId, ps.untilId);
+
+ return this.chatEntityService.packRoomMemberships(memberships, me, {
+ populateUser: false,
+ populateRoom: true,
+ });
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/leave.ts b/packages/backend/src/server/api/endpoints/chat/rooms/leave.ts
new file mode 100644
index 0000000000..724ad61f7e
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/leave.ts
@@ -0,0 +1,48 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'write:chat',
+
+ res: {
+ },
+
+ errors: {
+ noSuchRoom: {
+ message: 'No such room.',
+ code: 'NO_SUCH_ROOM',
+ id: 'cb7f3179-50e8-4389-8c30-dbe2650a67c9',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ roomId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['roomId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatService: ChatService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ await this.chatService.leaveRoom(me.id, ps.roomId);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/members.ts b/packages/backend/src/server/api/endpoints/chat/rooms/members.ts
new file mode 100644
index 0000000000..407bfe74f1
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/members.ts
@@ -0,0 +1,74 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'write:chat',
+
+ res: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatRoomMembership',
+ },
+ },
+
+ errors: {
+ noSuchRoom: {
+ message: 'No such room.',
+ code: 'NO_SUCH_ROOM',
+ id: '7b9fe84c-eafc-4d21-bf89-485458ed2c18',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ roomId: { type: 'string', format: 'misskey:id' },
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
+ sinceId: { type: 'string', format: 'misskey:id' },
+ untilId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['roomId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatService: ChatService,
+ private chatEntityService: ChatEntityService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const room = await this.chatService.findRoomById(ps.roomId);
+ if (room == null) {
+ throw new ApiError(meta.errors.noSuchRoom);
+ }
+
+ if (!(await this.chatService.isRoomMember(room, me.id))) {
+ throw new ApiError(meta.errors.noSuchRoom);
+ }
+
+ const memberships = await this.chatService.getRoomMembershipsWithPagination(room.id, ps.limit, ps.sinceId, ps.untilId);
+
+ return this.chatEntityService.packRoomMemberships(memberships, me, {
+ populateUser: true,
+ populateRoom: false,
+ });
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/mute.ts b/packages/backend/src/server/api/endpoints/chat/rooms/mute.ts
new file mode 100644
index 0000000000..5208b8a253
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/mute.ts
@@ -0,0 +1,49 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'write:chat',
+
+ res: {
+ },
+
+ errors: {
+ noSuchRoom: {
+ message: 'No such room.',
+ code: 'NO_SUCH_ROOM',
+ id: 'c2cde4eb-8d0f-42f1-8f2f-c4d6bfc8e5df',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ roomId: { type: 'string', format: 'misskey:id' },
+ mute: { type: 'boolean' },
+ },
+ required: ['roomId', 'mute'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatService: ChatService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ await this.chatService.muteRoom(me.id, ps.roomId, ps.mute);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts b/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts
new file mode 100644
index 0000000000..6516120bca
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts
@@ -0,0 +1,54 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'read:chat',
+
+ res: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatRoom',
+ },
+ },
+
+ errors: {
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
+ sinceId: { type: 'string', format: 'misskey:id' },
+ untilId: { type: 'string', format: 'misskey:id' },
+ },
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatEntityService: ChatEntityService,
+ private chatService: ChatService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const rooms = await this.chatService.getOwnedRoomsWithPagination(me.id, ps.limit, ps.sinceId, ps.untilId);
+ return this.chatEntityService.packRooms(rooms, me);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/show.ts b/packages/backend/src/server/api/endpoints/chat/rooms/show.ts
new file mode 100644
index 0000000000..547618ee7d
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/show.ts
@@ -0,0 +1,58 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'read:chat',
+
+ res: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatRoom',
+ },
+
+ errors: {
+ noSuchRoom: {
+ message: 'No such room.',
+ code: 'NO_SUCH_ROOM',
+ id: '857ae02f-8759-4d20-9adb-6e95fffe4fd7',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ roomId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['roomId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatService: ChatService,
+ private chatEntityService: ChatEntityService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const room = await this.chatService.findRoomById(ps.roomId);
+ if (room == null) {
+ throw new ApiError(meta.errors.noSuchRoom);
+ }
+
+ return this.chatEntityService.packRoom(room, me);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/update.ts b/packages/backend/src/server/api/endpoints/chat/rooms/update.ts
new file mode 100644
index 0000000000..6f2a9c10b5
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/chat/rooms/update.ts
@@ -0,0 +1,65 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ChatService } from '@/core/ChatService.js';
+import { ApiError } from '@/server/api/error.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+
+export const meta = {
+ tags: ['chat'],
+
+ requireCredential: true,
+
+ kind: 'write:chat',
+
+ res: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'ChatRoom',
+ },
+
+ errors: {
+ noSuchRoom: {
+ message: 'No such room.',
+ code: 'NO_SUCH_ROOM',
+ id: 'fcdb0f92-bda6-47f9-bd05-343e0e020932',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ roomId: { type: 'string', format: 'misskey:id' },
+ name: { type: 'string', maxLength: 256 },
+ description: { type: 'string', maxLength: 1024 },
+ },
+ required: ['roomId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private chatService: ChatService,
+ private chatEntityService: ChatEntityService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const room = await this.chatService.findMyRoomById(me.id, ps.roomId);
+ if (room == null) {
+ throw new ApiError(meta.errors.noSuchRoom);
+ }
+
+ const updated = await this.chatService.updateRoom(room, {
+ name: ps.name,
+ description: ps.description,
+ });
+
+ return this.chatEntityService.packRoom(updated, me);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts
index dc6ffd3e02..88d7f51c26 100644
--- a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts
+++ b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts
@@ -9,7 +9,6 @@ import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository } from '@/models/_.js';
import { obsoleteNotificationTypes, groupedNotificationTypes, FilterUnionByProperty } from '@/types.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
-import { NoteReadService } from '@/core/NoteReadService.js';
import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js';
import { NotificationService } from '@/core/NotificationService.js';
import { DI } from '@/di-symbols.js';
@@ -63,13 +62,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.redis)
private redisClient: Redis.Redis,
- @Inject(DI.notesRepository)
- private notesRepository: NotesRepository,
-
private idService: IdService,
private notificationEntityService: NotificationEntityService,
private notificationService: NotificationService,
- private noteReadService: NoteReadService,
) {
super(meta, paramDef, async (ps, me) => {
const EXTRA_LIMIT = 100;
@@ -162,14 +157,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
groupedNotifications = groupedNotifications.slice(0, ps.limit);
- const noteIds = groupedNotifications
- .filter((notification): notification is FilterUnionByProperty<MiNotification, 'type', 'mention' | 'reply' | 'quote'> => ['mention', 'reply', 'quote'].includes(notification.type))
- .map(notification => notification.noteId!);
-
- if (noteIds.length > 0) {
- const notes = await this.notesRepository.findBy({ id: In(noteIds) });
- this.noteReadService.read(me.id, notes);
- }
return await this.notificationEntityService.packGroupedMany(groupedNotifications, me.id);
});
diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts
index 2f619380e9..be8d0cfb34 100644
--- a/packages/backend/src/server/api/endpoints/i/notifications.ts
+++ b/packages/backend/src/server/api/endpoints/i/notifications.ts
@@ -9,7 +9,6 @@ import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository } from '@/models/_.js';
import { FilterUnionByProperty, notificationTypes, obsoleteNotificationTypes } from '@/types.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
-import { NoteReadService } from '@/core/NoteReadService.js';
import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js';
import { NotificationService } from '@/core/NotificationService.js';
import { DI } from '@/di-symbols.js';
@@ -69,7 +68,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private idService: IdService,
private notificationEntityService: NotificationEntityService,
private notificationService: NotificationService,
- private noteReadService: NoteReadService,
) {
super(meta, paramDef, async (ps, me) => {
// includeTypes が空の場合はクエリしない
@@ -136,15 +134,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.notificationService.readAllNotification(me.id);
}
- const noteIds = notifications
- .filter((notification): notification is FilterUnionByProperty<MiNotification, 'type', 'mention' | 'reply' | 'quote'> => ['mention', 'reply', 'quote'].includes(notification.type))
- .map(notification => notification.noteId);
-
- if (noteIds.length > 0) {
- const notes = await this.notesRepository.findBy({ id: In(noteIds) });
- this.noteReadService.read(me.id, notes);
- }
-
return await this.notificationEntityService.packMany(notifications, me.id);
});
}
diff --git a/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts b/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts
deleted file mode 100644
index d1a8eccb1d..0000000000
--- a/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { Inject, Injectable } from '@nestjs/common';
-import { Endpoint } from '@/server/api/endpoint-base.js';
-import type { NoteUnreadsRepository } from '@/models/_.js';
-import { GlobalEventService } from '@/core/GlobalEventService.js';
-import { DI } from '@/di-symbols.js';
-
-export const meta = {
- tags: ['account'],
-
- requireCredential: true,
-
- kind: 'write:account',
-} as const;
-
-export const paramDef = {
- type: 'object',
- properties: {},
- required: [],
-} as const;
-
-@Injectable()
-export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
- constructor(
- @Inject(DI.noteUnreadsRepository)
- private noteUnreadsRepository: NoteUnreadsRepository,
-
- private globalEventService: GlobalEventService,
- ) {
- super(meta, paramDef, async (ps, me) => {
- // Remove documents
- await this.noteUnreadsRepository.delete({
- userId: me.id,
- });
-
- // 全て既読になったイベントを発行
- this.globalEventService.publishMainStream(me.id, 'readAllUnreadMentions');
- this.globalEventService.publishMainStream(me.id, 'readAllUnreadSpecifiedNotes');
- });
- }
-}
diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts
index 4c72879b73..baf397bf06 100644
--- a/packages/backend/src/server/api/endpoints/i/update.ts
+++ b/packages/backend/src/server/api/endpoints/i/update.ts
@@ -190,6 +190,7 @@ export const paramDef = {
autoSensitive: { type: 'boolean' },
followingVisibility: { type: 'string', enum: ['public', 'followers', 'private'] },
followersVisibility: { type: 'string', enum: ['public', 'followers', 'private'] },
+ chatScope: { type: 'string', enum: ['everyone', 'followers', 'following', 'mutual', 'none'] },
pinnedPageId: { type: 'string', format: 'misskey:id', nullable: true },
mutedWords: muteWords,
hardMutedWords: muteWords,
@@ -288,6 +289,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday;
if (ps.followingVisibility !== undefined) profileUpdates.followingVisibility = ps.followingVisibility;
if (ps.followersVisibility !== undefined) profileUpdates.followersVisibility = ps.followersVisibility;
+ if (ps.chatScope !== undefined) updates.chatScope = ps.chatScope;
function checkMuteWordCount(mutedWords: (string[] | string)[], limit: number) {
// TODO: ちゃんと数える
diff --git a/packages/backend/src/server/api/endpoints/notes/mentions.ts b/packages/backend/src/server/api/endpoints/notes/mentions.ts
index 5558dd3a8b..18a3915ab5 100644
--- a/packages/backend/src/server/api/endpoints/notes/mentions.ts
+++ b/packages/backend/src/server/api/endpoints/notes/mentions.ts
@@ -9,7 +9,6 @@ import type { NotesRepository, FollowingsRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
-import { NoteReadService } from '@/core/NoteReadService.js';
import { DI } from '@/di-symbols.js';
export const meta = {
@@ -52,7 +51,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private noteEntityService: NoteEntityService,
private queryService: QueryService,
- private noteReadService: NoteReadService,
) {
super(meta, paramDef, async (ps, me) => {
const followingQuery = this.followingsRepository.createQueryBuilder('following')
@@ -89,8 +87,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const mentions = await query.limit(ps.limit).getMany();
- this.noteReadService.read(me.id, mentions);
-
return await this.noteEntityService.packMany(mentions, me);
});
}
diff --git a/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts
index 732d644a29..29c6aa7434 100644
--- a/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts
@@ -9,7 +9,6 @@ import type { NotesRepository, NoteThreadMutingsRepository } from '@/models/_.js
import { IdService } from '@/core/IdService.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { GetterService } from '@/server/api/GetterService.js';
-import { NoteReadService } from '@/core/NoteReadService.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../../error.js';
@@ -52,7 +51,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private noteThreadMutingsRepository: NoteThreadMutingsRepository,
private getterService: GetterService,
- private noteReadService: NoteReadService,
private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
@@ -69,8 +67,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}],
});
- await this.noteReadService.read(me.id, mutedNotes);
-
await this.noteThreadMutingsRepository.insert({
id: this.idService.gen(),
threadId: note.threadId ?? note.id,
diff --git a/packages/backend/src/server/api/stream/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts
index 253409259f..c0ef589dea 100644
--- a/packages/backend/src/server/api/stream/ChannelsService.ts
+++ b/packages/backend/src/server/api/stream/ChannelsService.ts
@@ -19,6 +19,8 @@ import { AntennaChannelService } from './channels/antenna.js';
import { DriveChannelService } from './channels/drive.js';
import { HashtagChannelService } from './channels/hashtag.js';
import { RoleTimelineChannelService } from './channels/role-timeline.js';
+import { ChatUserChannelService } from './channels/chat-user.js';
+import { ChatRoomChannelService } from './channels/chat-room.js';
import { ReversiChannelService } from './channels/reversi.js';
import { ReversiGameChannelService } from './channels/reversi-game.js';
import { type MiChannelService } from './channel.js';
@@ -40,6 +42,8 @@ export class ChannelsService {
private serverStatsChannelService: ServerStatsChannelService,
private queueStatsChannelService: QueueStatsChannelService,
private adminChannelService: AdminChannelService,
+ private chatUserChannelService: ChatUserChannelService,
+ private chatRoomChannelService: ChatRoomChannelService,
private reversiChannelService: ReversiChannelService,
private reversiGameChannelService: ReversiGameChannelService,
) {
@@ -62,6 +66,8 @@ export class ChannelsService {
case 'serverStats': return this.serverStatsChannelService;
case 'queueStats': return this.queueStatsChannelService;
case 'admin': return this.adminChannelService;
+ case 'chatUser': return this.chatUserChannelService;
+ case 'chatRoom': return this.chatRoomChannelService;
case 'reversi': return this.reversiChannelService;
case 'reversiGame': return this.reversiGameChannelService;
diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts
index 0fb5238c78..c9801d8314 100644
--- a/packages/backend/src/server/api/stream/Connection.ts
+++ b/packages/backend/src/server/api/stream/Connection.ts
@@ -7,7 +7,6 @@ import * as WebSocket from 'ws';
import type { MiUser } from '@/models/User.js';
import type { MiAccessToken } from '@/models/AccessToken.js';
import type { Packed } from '@/misc/json-schema.js';
-import type { NoteReadService } from '@/core/NoteReadService.js';
import type { NotificationService } from '@/core/NotificationService.js';
import { bindThis } from '@/decorators.js';
import { CacheService } from '@/core/CacheService.js';
@@ -45,7 +44,6 @@ export default class Connection {
constructor(
private channelsService: ChannelsService,
- private noteReadService: NoteReadService,
private notificationService: NotificationService,
private cacheService: CacheService,
private channelFollowingService: ChannelFollowingService,
@@ -119,7 +117,7 @@ export default class Connection {
case 'readNotification': this.onReadNotification(body); break;
case 'subNote': this.onSubscribeNote(body); break;
case 's': this.onSubscribeNote(body); break; // alias
- case 'sr': this.onSubscribeNote(body); this.readNote(body); break;
+ case 'sr': this.onSubscribeNote(body); break;
case 'unsubNote': this.onUnsubscribeNote(body); break;
case 'un': this.onUnsubscribeNote(body); break; // alias
case 'connect': this.onChannelConnectRequested(body); break;
@@ -155,19 +153,6 @@ export default class Connection {
}
@bindThis
- private readNote(body: JsonValue | undefined) {
- if (!isJsonObject(body)) return;
- const id = body.id;
-
- const note = this.cachedNotes.find(n => n.id === id);
- if (note == null) return;
-
- if (this.user && (note.userId !== this.user.id)) {
- this.noteReadService.read(this.user.id, [note]);
- }
- }
-
- @bindThis
private onReadNotification(payload: JsonValue | undefined) {
this.notificationService.readAllNotification(this.user!.id);
}
diff --git a/packages/backend/src/server/api/stream/channels/chat-room.ts b/packages/backend/src/server/api/stream/channels/chat-room.ts
new file mode 100644
index 0000000000..e989969345
--- /dev/null
+++ b/packages/backend/src/server/api/stream/channels/chat-room.ts
@@ -0,0 +1,78 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { bindThis } from '@/decorators.js';
+import type { GlobalEvents } from '@/core/GlobalEventService.js';
+import type { JsonObject } from '@/misc/json-value.js';
+import { ChatService } from '@/core/ChatService.js';
+import Channel, { type MiChannelService } from '../channel.js';
+
+class ChatRoomChannel extends Channel {
+ public readonly chName = 'chatRoom';
+ public static shouldShare = false;
+ public static requireCredential = true as const;
+ public static kind = 'read:chat';
+ private roomId: string;
+
+ constructor(
+ private chatService: ChatService,
+
+ id: string,
+ connection: Channel['connection'],
+ ) {
+ super(id, connection);
+ }
+
+ @bindThis
+ public async init(params: JsonObject) {
+ if (typeof params.roomId !== 'string') return;
+ this.roomId = params.roomId;
+
+ this.subscriber.on(`chatRoomStream:${this.roomId}`, this.onEvent);
+ }
+
+ @bindThis
+ private async onEvent(data: GlobalEvents['chat']['payload']) {
+ this.send(data.type, data.body);
+ }
+
+ @bindThis
+ public onMessage(type: string, body: any) {
+ switch (type) {
+ case 'read':
+ if (this.roomId) {
+ this.chatService.readRoomChatMessage(this.user!.id, this.roomId);
+ }
+ break;
+ }
+ }
+
+ @bindThis
+ public dispose() {
+ this.subscriber.off(`chatRoomStream:${this.roomId}`, this.onEvent);
+ }
+}
+
+@Injectable()
+export class ChatRoomChannelService implements MiChannelService<true> {
+ public readonly shouldShare = ChatRoomChannel.shouldShare;
+ public readonly requireCredential = ChatRoomChannel.requireCredential;
+ public readonly kind = ChatRoomChannel.kind;
+
+ constructor(
+ private chatService: ChatService,
+ ) {
+ }
+
+ @bindThis
+ public create(id: string, connection: Channel['connection']): ChatRoomChannel {
+ return new ChatRoomChannel(
+ this.chatService,
+ id,
+ connection,
+ );
+ }
+}
diff --git a/packages/backend/src/server/api/stream/channels/chat-user.ts b/packages/backend/src/server/api/stream/channels/chat-user.ts
new file mode 100644
index 0000000000..c4e898cd5b
--- /dev/null
+++ b/packages/backend/src/server/api/stream/channels/chat-user.ts
@@ -0,0 +1,78 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { bindThis } from '@/decorators.js';
+import type { GlobalEvents } from '@/core/GlobalEventService.js';
+import type { JsonObject } from '@/misc/json-value.js';
+import { ChatService } from '@/core/ChatService.js';
+import Channel, { type MiChannelService } from '../channel.js';
+
+class ChatUserChannel extends Channel {
+ public readonly chName = 'chatUser';
+ public static shouldShare = false;
+ public static requireCredential = true as const;
+ public static kind = 'read:chat';
+ private otherId: string;
+
+ constructor(
+ private chatService: ChatService,
+
+ id: string,
+ connection: Channel['connection'],
+ ) {
+ super(id, connection);
+ }
+
+ @bindThis
+ public async init(params: JsonObject) {
+ if (typeof params.otherId !== 'string') return;
+ this.otherId = params.otherId;
+
+ this.subscriber.on(`chatUserStream:${this.user!.id}-${this.otherId}`, this.onEvent);
+ }
+
+ @bindThis
+ private async onEvent(data: GlobalEvents['chat']['payload']) {
+ this.send(data.type, data.body);
+ }
+
+ @bindThis
+ public onMessage(type: string, body: any) {
+ switch (type) {
+ case 'read':
+ if (this.otherId) {
+ this.chatService.readUserChatMessage(this.user!.id, this.otherId);
+ }
+ break;
+ }
+ }
+
+ @bindThis
+ public dispose() {
+ this.subscriber.off(`chatUserStream:${this.user!.id}-${this.otherId}`, this.onEvent);
+ }
+}
+
+@Injectable()
+export class ChatUserChannelService implements MiChannelService<true> {
+ public readonly shouldShare = ChatUserChannel.shouldShare;
+ public readonly requireCredential = ChatUserChannel.requireCredential;
+ public readonly kind = ChatUserChannel.kind;
+
+ constructor(
+ private chatService: ChatService,
+ ) {
+ }
+
+ @bindThis
+ public create(id: string, connection: Channel['connection']): ChatUserChannel {
+ return new ChatUserChannel(
+ this.chatService,
+ id,
+ connection,
+ );
+ }
+}
diff --git a/packages/backend/test-federation/test/timeline.test.ts b/packages/backend/test-federation/test/timeline.test.ts
index 2250bf4a42..00635e654b 100644
--- a/packages/backend/test-federation/test/timeline.test.ts
+++ b/packages/backend/test-federation/test/timeline.test.ts
@@ -24,7 +24,7 @@ describe('Timeline', () => {
});
type TimelineChannel = keyof Misskey.Channels & (`${string}Timeline` | 'antenna' | 'userList' | 'hashtag');
- type TimelineEndpoint = keyof Misskey.Endpoints & (`${string}timeline` | 'antennas/notes' | 'roles/notes' | 'notes/search-by-tag');
+ type TimelineEndpoint = keyof Misskey.Endpoints & (`notes/${string}timeline` | 'antennas/notes' | 'roles/notes' | 'notes/search-by-tag');
const timelineMap = new Map<TimelineChannel, TimelineEndpoint>([
['antenna', 'antennas/notes'],
['globalTimeline', 'notes/global-timeline'],
diff --git a/packages/backend/test/e2e/mute.ts b/packages/backend/test/e2e/mute.ts
index f37da288b7..b464c24287 100644
--- a/packages/backend/test/e2e/mute.ts
+++ b/packages/backend/test/e2e/mute.ts
@@ -51,30 +51,8 @@ describe('Mute', () => {
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
});
- test('ミュートしているユーザーからメンションされても、hasUnreadMentions が true にならない', async () => {
- // 状態リセット
- await api('i/read-all-unread-notes', {}, alice);
-
- await post(carol, { text: '@alice hi' });
-
- const res = await api('i', {}, alice);
-
- assert.strictEqual(res.status, 200);
- assert.strictEqual(res.body.hasUnreadMentions, false);
- });
-
- test('ミュートしているユーザーからメンションされても、ストリームに unreadMention イベントが流れてこない', async () => {
- // 状態リセット
- await api('i/read-all-unread-notes', {}, alice);
-
- const fired = await waitFire(alice, 'main', () => post(carol, { text: '@alice hi' }), msg => msg.type === 'unreadMention');
-
- assert.strictEqual(fired, false);
- });
-
test('ミュートしているユーザーからメンションされても、ストリームに unreadNotification イベントが流れてこない', async () => {
// 状態リセット
- await api('i/read-all-unread-notes', {}, alice);
await api('notifications/mark-all-as-read', {}, alice);
const fired = await waitFire(alice, 'main', () => post(carol, { text: '@alice hi' }), msg => msg.type === 'unreadNotification');
diff --git a/packages/backend/test/e2e/thread-mute.ts b/packages/backend/test/e2e/thread-mute.ts
index 1ac99df884..1edc178fc2 100644
--- a/packages/backend/test/e2e/thread-mute.ts
+++ b/packages/backend/test/e2e/thread-mute.ts
@@ -38,48 +38,6 @@ describe('Note thread mute', () => {
assert.strictEqual(res.body.some(note => note.id === carolReplyWithoutMention.id), false);
});
- test('ミュートしているスレッドからメンションされても、hasUnreadMentions が true にならない', async () => {
- // 状態リセット
- await api('i/read-all-unread-notes', {}, alice);
-
- const bobNote = await post(bob, { text: '@alice @carol root note' });
-
- await api('notes/thread-muting/create', { noteId: bobNote.id }, alice);
-
- const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' });
-
- const res = await api('i', {}, alice);
-
- assert.strictEqual(res.status, 200);
- assert.strictEqual(res.body.hasUnreadMentions, false);
- });
-
- test('ミュートしているスレッドからメンションされても、ストリームに unreadMention イベントが流れてこない', () => new Promise<void>(async done => {
- // 状態リセット
- await api('i/read-all-unread-notes', {}, alice);
-
- const bobNote = await post(bob, { text: '@alice @carol root note' });
-
- await api('notes/thread-muting/create', { noteId: bobNote.id }, alice);
-
- let fired = false;
-
- const ws = await connectStream(alice, 'main', async ({ type, body }) => {
- if (type === 'unreadMention') {
- if (body === bobNote.id) return;
- fired = true;
- }
- });
-
- const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' });
-
- setTimeout(() => {
- assert.strictEqual(fired, false);
- ws.close();
- done();
- }, 5000);
- }));
-
test('i/notifications にミュートしているスレッドの通知が含まれない', async () => {
const bobNote = await post(bob, { text: '@alice @carol root note' });
const aliceReply = await post(alice, { replyId: bobNote.id, text: '@bob @carol child note' });
diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts
index 822ca14ae6..f82d4c6a30 100644
--- a/packages/backend/test/e2e/users.ts
+++ b/packages/backend/test/e2e/users.ts
@@ -15,7 +15,7 @@ describe('ユーザー', () => {
// エンティティとしてのユーザーを主眼においたテストを記述する
// (Userを返すエンドポイントとUserエンティティを書き換えるエンドポイントをテストする)
- const stripUndefined = <T extends { [key: string]: any }, >(orig: T): Partial<T> => {
+ const stripUndefined = <T extends { [key: string]: any } >(orig: T): Partial<T> => {
return Object.entries({ ...orig })
.filter(([, value]) => value !== undefined)
.reduce((obj: Partial<T>, [key, value]) => {
@@ -83,6 +83,7 @@ describe('ユーザー', () => {
publicReactions: user.publicReactions,
followingVisibility: user.followingVisibility,
followersVisibility: user.followersVisibility,
+ chatScope: user.chatScope,
roles: user.roles,
memo: user.memo,
});
@@ -132,6 +133,7 @@ describe('ユーザー', () => {
hasUnreadAnnouncement: user.hasUnreadAnnouncement,
hasUnreadAntenna: user.hasUnreadAntenna,
hasUnreadChannel: user.hasUnreadChannel,
+ hasUnreadChatMessages: user.hasUnreadChatMessages,
hasUnreadNotification: user.hasUnreadNotification,
unreadNotificationsCount: user.unreadNotificationsCount,
hasPendingReceivedFollowRequest: user.hasPendingReceivedFollowRequest,
@@ -343,6 +345,7 @@ describe('ユーザー', () => {
assert.strictEqual(response.publicReactions, true);
assert.strictEqual(response.followingVisibility, 'public');
assert.strictEqual(response.followersVisibility, 'public');
+ assert.strictEqual(response.chatScope, 'mutual');
assert.deepStrictEqual(response.roles, []);
assert.strictEqual(response.memo, null);
@@ -369,6 +372,7 @@ describe('ユーザー', () => {
assert.strictEqual(response.hasUnreadAnnouncement, false);
assert.strictEqual(response.hasUnreadAntenna, false);
assert.strictEqual(response.hasUnreadChannel, false);
+ assert.strictEqual(response.hasUnreadChatMessages, false);
assert.strictEqual(response.hasUnreadNotification, false);
assert.strictEqual(response.unreadNotificationsCount, 0);
assert.strictEqual(response.hasPendingReceivedFollowRequest, false);
diff --git a/packages/backend/test/unit/entities/UserEntityService.ts b/packages/backend/test/unit/entities/UserEntityService.ts
index e4f42809f8..6b7eedff55 100644
--- a/packages/backend/test/unit/entities/UserEntityService.ts
+++ b/packages/backend/test/unit/entities/UserEntityService.ts
@@ -50,6 +50,7 @@ import { AccountMoveService } from '@/core/AccountMoveService.js';
import { ReactionService } from '@/core/ReactionService.js';
import { NotificationService } from '@/core/NotificationService.js';
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
+import { ChatService } from '@/core/ChatService.js';
process.env.NODE_ENV = 'test';
@@ -172,6 +173,7 @@ describe('UserEntityService', () => {
ReactionService,
ReactionsBufferingService,
NotificationService,
+ ChatService,
];
app = await Test.createTestingModule({
diff --git a/packages/frontend-embed/src/components/EmPagination.vue b/packages/frontend-embed/src/components/EmPagination.vue
index 5d5317a912..4cf156ba23 100644
--- a/packages/frontend-embed/src/components/EmPagination.vue
+++ b/packages/frontend-embed/src/components/EmPagination.vue
@@ -34,10 +34,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts">
-import { computed, ComputedRef, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, shallowRef, watch } from 'vue';
+import { computed, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, shallowRef, watch } from 'vue';
import * as Misskey from 'misskey-js';
import { useDocumentVisibility } from '@@/js/use-document-visibility.js';
-import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@@/js/scroll.js';
+import { onScrollTop, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isTailVisible, isHeadVisible } from '@@/js/scroll.js';
+import type { ComputedRef } from 'vue';
import { misskeyApi } from '@/misskey-api.js';
import { i18n } from '@/i18n.js';
@@ -62,8 +63,6 @@ export type Paging<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints>
reversed?: boolean;
offsetMode?: boolean;
-
- pageEl?: HTMLElement;
};
type MisskeyEntity = {
@@ -135,8 +134,7 @@ const isBackTop = ref(false);
const empty = computed(() => items.value.size === 0);
const error = ref(false);
-const contentEl = computed(() => props.pagination.pageEl ?? rootEl.value);
-const scrollableElement = computed(() => contentEl.value ? getScrollContainer(contentEl.value) : document.body);
+const scrollableElement = computed(() => rootEl.value ? getScrollContainer(rootEl.value) : document.body);
const visibility = useDocumentVisibility();
@@ -167,11 +165,11 @@ watch(rootEl, () => {
});
});
-watch([backed, contentEl], () => {
+watch([backed, rootEl], () => {
if (!backed.value) {
- if (!contentEl.value) return;
+ if (!rootEl.value) return;
- scrollRemove.value = (props.pagination.reversed ? onScrollBottom : onScrollTop)(contentEl.value, executeQueue, TOLERANCE);
+ scrollRemove.value = (props.pagination.reversed ? onScrollBottom : onScrollTop)(rootEl.value, executeQueue, TOLERANCE);
} else {
if (scrollRemove.value) scrollRemove.value();
scrollRemove.value = null;
@@ -344,7 +342,7 @@ const appearFetchMoreAhead = async (): Promise<void> => {
fetchMoreAppearTimeout();
};
-const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl.value!, TOLERANCE);
+const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isTailVisible : isHeadVisible)(rootEl.value!, TOLERANCE);
watch(visibility, () => {
if (visibility.value === 'hidden') {
@@ -442,7 +440,7 @@ onDeactivated(() => {
});
function toBottom() {
- scrollToBottom(contentEl.value!);
+ scrollToBottom(rootEl.value!);
}
onBeforeMount(() => {
diff --git a/packages/frontend-shared/js/const.ts b/packages/frontend-shared/js/const.ts
index 9b821e650a..0241446784 100644
--- a/packages/frontend-shared/js/const.ts
+++ b/packages/frontend-shared/js/const.ts
@@ -107,6 +107,7 @@ export const ROLE_POLICIES = [
'canImportFollowing',
'canImportMuting',
'canImportUserLists',
+ 'canChat',
] as const;
export const DEFAULT_SERVER_ERROR_IMAGE_URL = 'https://xn--931a.moe/assets/error.jpg';
diff --git a/packages/frontend-shared/js/scroll.ts b/packages/frontend-shared/js/scroll.ts
index 508864b12c..6c61c582e1 100644
--- a/packages/frontend-shared/js/scroll.ts
+++ b/packages/frontend-shared/js/scroll.ts
@@ -38,7 +38,7 @@ export function getScrollPosition(el: HTMLElement | null): number {
export function onScrollTop(el: HTMLElement, cb: (topVisible: boolean) => unknown, tolerance = 1, once = false) {
// とりあえず評価してみる
- const firstTopVisible = isTopVisible(el);
+ const firstTopVisible = isHeadVisible(el);
if (el.isConnected && firstTopVisible) {
cb(firstTopVisible);
if (once) return null;
@@ -53,7 +53,7 @@ export function onScrollTop(el: HTMLElement, cb: (topVisible: boolean) => unknow
const onScroll = () => {
if (!document.body.contains(el)) return;
- const topVisible = isTopVisible(el, tolerance);
+ const topVisible = isHeadVisible(el, tolerance);
if (topVisible !== prevTopVisible) {
prevTopVisible = topVisible;
cb(topVisible);
@@ -71,7 +71,7 @@ export function onScrollBottom(el: HTMLElement, cb: () => unknown, tolerance = 1
const container = getScrollContainer(el);
// とりあえず評価してみる
- if (el.isConnected && isBottomVisible(el, tolerance, container)) {
+ if (el.isConnected && isTailVisible(el, tolerance, container)) {
cb();
if (once) return null;
}
@@ -79,7 +79,7 @@ export function onScrollBottom(el: HTMLElement, cb: () => unknown, tolerance = 1
const containerOrWindow = container ?? window;
const onScroll = () => {
if (!document.body.contains(el)) return;
- if (isBottomVisible(el, 1, container)) {
+ if (isTailVisible(el, 1, container)) {
cb();
if (once) removeListener();
}
@@ -132,12 +132,12 @@ export function scrollToBottom(
}
}
-export function isTopVisible(el: HTMLElement, tolerance = 1): boolean {
+export function isHeadVisible(el: HTMLElement, tolerance = 1): boolean {
const scrollTop = getScrollPosition(el);
return scrollTop <= tolerance;
}
-export function isBottomVisible(el: HTMLElement, tolerance = 1, container = getScrollContainer(el)) {
+export function isTailVisible(el: HTMLElement, tolerance = 1, container = getScrollContainer(el)) {
if (container) return el.scrollHeight <= container.clientHeight + Math.abs(container.scrollTop) + tolerance;
return el.scrollHeight <= window.innerHeight + window.scrollY + tolerance;
}
diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts
index 134490e317..19371dff0e 100644
--- a/packages/frontend/src/boot/main-boot.ts
+++ b/packages/frontend/src/boot/main-boot.ts
@@ -502,31 +502,16 @@ export async function mainBoot() {
});
});
- main.on('unreadMention', () => {
- updateCurrentAccountPartial({ hasUnreadMentions: true });
- });
-
- main.on('readAllUnreadMentions', () => {
- updateCurrentAccountPartial({ hasUnreadMentions: false });
- });
-
- main.on('unreadSpecifiedNote', () => {
- updateCurrentAccountPartial({ hasUnreadSpecifiedNotes: true });
- });
-
- main.on('readAllUnreadSpecifiedNotes', () => {
- updateCurrentAccountPartial({ hasUnreadSpecifiedNotes: false });
- });
-
- main.on('readAllAntennas', () => {
- updateCurrentAccountPartial({ hasUnreadAntenna: false });
- });
-
main.on('unreadAntenna', () => {
updateCurrentAccountPartial({ hasUnreadAntenna: true });
sound.playMisskeySfx('antenna');
});
+ main.on('newChatMessage', () => {
+ updateCurrentAccountPartial({ hasUnreadChatMessages: true });
+ sound.playMisskeySfx('chat');
+ });
+
main.on('readAllAnnouncements', () => {
updateCurrentAccountPartial({ hasUnreadAnnouncement: false });
});
diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue
index 5e89dfba12..891af7f696 100644
--- a/packages/frontend/src/components/MkButton.vue
+++ b/packages/frontend/src/components/MkButton.vue
@@ -7,11 +7,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<button
v-if="!link"
ref="el" class="_button"
- :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly }]"
+ :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]"
:type="type"
:name="name"
:value="value"
- :disabled="disabled"
+ :disabled="disabled || wait"
@click="emit('click', $event)"
@mousedown="onMousedown"
>
@@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</button>
<MkA
v-else class="_button"
- :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly }]"
+ :class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]"
:to="to ?? '#'"
:behavior="linkBehavior"
@mousedown="onMousedown"
@@ -256,6 +256,10 @@ function onMousedown(evt: MouseEvent): void {
opacity: 0.5;
}
+ &.wait {
+ cursor: wait !important;
+ }
+
&:focus-visible {
outline-offset: 2px;
}
diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue
index b5842876ac..ec6fcdc311 100644
--- a/packages/frontend/src/components/MkDateSeparatedList.vue
+++ b/packages/frontend/src/components/MkDateSeparatedList.vue
@@ -168,21 +168,17 @@ export default defineComponent({
container-type: inline-size;
&:global {
- > .list-move {
- transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
- }
-
- &.deny-move-transition > .list-move {
- transition: none !important;
- }
+ > .list-move {
+ transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
+ }
- > .list-enter-active {
- transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
- }
+ > .list-enter-active {
+ transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
+ }
- > *:empty {
- display: none;
- }
+ > *:empty {
+ display: none;
+ }
}
&:not(.date-separated-list-nogap) > *:not(:last-child) {
diff --git a/packages/frontend/src/components/MkFukidashi.vue b/packages/frontend/src/components/MkFukidashi.vue
index 8b1c56fca4..e9544afa35 100644
--- a/packages/frontend/src/components/MkFukidashi.vue
+++ b/packages/frontend/src/components/MkFukidashi.vue
@@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
tail === 'left' ? $style.left : $style.right,
negativeMargin === true && $style.negativeMargin,
shadow === true && $style.shadow,
+ accented === true && $style.accented
]"
>
<div :class="$style.bg">
@@ -30,10 +31,12 @@ withDefaults(defineProps<{
tail?: 'left' | 'right' | 'none';
negativeMargin?: boolean;
shadow?: boolean;
+ accented?: boolean;
}>(), {
tail: 'right',
negativeMargin: false,
shadow: false,
+ accented: false,
});
</script>
@@ -47,6 +50,10 @@ withDefaults(defineProps<{
min-height: calc(var(--fukidashi-radius) * 2);
padding-top: calc(var(--fukidashi-radius) * .13);
+ &.accented {
+ --fukidashi-bg: var(--MI_THEME-accent);
+ }
+
&.shadow {
filter: drop-shadow(0 4px 32px var(--MI_THEME-shadow));
}
@@ -77,7 +84,7 @@ withDefaults(defineProps<{
.content {
position: relative;
- padding: 8px 12px;
+ padding: 10px 14px;
}
.tail {
diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue
index ae15776041..4a1100c324 100644
--- a/packages/frontend/src/components/MkMediaList.vue
+++ b/packages/frontend/src/components/MkMediaList.vue
@@ -227,7 +227,6 @@ defineExpose({
.container {
position: relative;
width: 100%;
- margin-top: 4px;
}
.medias {
diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue
index a84bd9b256..f2f36308ca 100644
--- a/packages/frontend/src/components/MkMenu.vue
+++ b/packages/frontend/src/components/MkMenu.vue
@@ -11,6 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
[$style.center]: align === 'center',
[$style.big]: big,
[$style.asDrawer]: asDrawer,
+ [$style.widthSpecified]: width != null,
}"
@focusin.passive.stop="() => {}"
>
@@ -29,15 +30,19 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<template v-for="item in (items2 ?? [])">
<div v-if="item.type === 'divider'" role="separator" tabindex="-1" :class="$style.divider"></div>
+
<span v-else-if="item.type === 'label'" role="menuitem" tabindex="-1" :class="[$style.label, $style.item]">
<span style="opacity: 0.7;">{{ item.text }}</span>
</span>
+
<span v-else-if="item.type === 'pending'" role="menuitem" tabindex="0" :class="[$style.pending, $style.item]">
<span><MkEllipsis/></span>
</span>
+
<div v-else-if="item.type === 'component'" role="menuitem" tabindex="-1" :class="[$style.componentItem]">
<component :is="item.component" v-bind="item.props"/>
</div>
+
<MkA
v-else-if="item.type === 'link'"
role="menuitem"
@@ -51,10 +56,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
<div :class="$style.item_content">
- <span :class="$style.item_content_text">{{ item.text }}</span>
+ <div :class="$style.item_content_text">
+ <div :class="$style.item_content_text_title">{{ item.text }}</div>
+ <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div>
+ </div>
<span v-if="item.indicate" :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span>
</div>
</MkA>
+
<a
v-else-if="item.type === 'a'"
role="menuitem"
@@ -70,10 +79,14 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
<div :class="$style.item_content">
- <span :class="$style.item_content_text">{{ item.text }}</span>
+ <div :class="$style.item_content_text">
+ <div :class="$style.item_content_text_title">{{ item.text }}</div>
+ <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div>
+ </div>
<span v-if="item.indicate" :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span>
</div>
</a>
+
<button
v-else-if="item.type === 'user'"
role="menuitem"
@@ -88,6 +101,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span>
</div>
</button>
+
<button
v-else-if="item.type === 'switch'"
role="menuitemcheckbox"
@@ -101,10 +115,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
<MkSwitchButton v-else :class="$style.switchButton" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/>
<div :class="$style.item_content">
- <span :class="[$style.item_content_text, { [$style.switchText]: !item.icon }]">{{ item.text }}</span>
+ <div :class="[$style.item_content_text, { [$style.switchText]: !item.icon }]">
+ <div :class="$style.item_content_text_title">{{ item.text }}</div>
+ <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div>
+ </div>
<MkSwitchButton v-if="item.icon" :class="[$style.switchButton, $style.caret]" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/>
</div>
</button>
+
<button
v-else-if="item.type === 'radio'"
role="menuitem"
@@ -117,10 +135,14 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i>
<div :class="$style.item_content">
- <span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span>
+ <div :class="$style.item_content_text" style="pointer-events: none;">
+ <div :class="$style.item_content_text_title">{{ item.text }}</div>
+ <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div>
+ </div>
<span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span>
</div>
</button>
+
<button
v-else-if="item.type === 'radioOption'"
role="menuitemradio"
@@ -134,9 +156,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<span :class="[$style.radioIcon, { [$style.radioChecked]: unref(item.active) }]"></span>
</div>
<div :class="$style.item_content">
- <span :class="$style.item_content_text">{{ item.text }}</span>
+ <div :class="$style.item_content_text">
+ <div :class="$style.item_content_text_title">{{ item.text }}</div>
+ <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div>
+ </div>
</div>
</button>
+
<button
v-else-if="item.type === 'parent'"
role="menuitem"
@@ -148,12 +174,17 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i>
<div :class="$style.item_content">
- <span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span>
+ <div :class="$style.item_content_text" style="pointer-events: none;">
+ <div :class="$style.item_content_text_title">{{ item.text }}</div>
+ <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div>
+ </div>
<span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span>
</div>
</button>
+
<button
- v-else role="menuitem"
+ v-else
+ role="menuitem"
tabindex="0"
:class="['_button', $style.item, { [$style.danger]: item.danger, [$style.active]: unref(item.active) }]"
@click.prevent="unref(item.active) ? close(false) : clicked(item.action, $event)"
@@ -163,11 +194,15 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
<div :class="$style.item_content">
- <span :class="$style.item_content_text">{{ item.text }}</span>
+ <div :class="$style.item_content_text">
+ <div :class="$style.item_content_text_title">{{ item.text }}</div>
+ <div v-if="item.caption" :class="$style.item_content_text_caption">{{ item.caption }}</div>
+ </div>
<span v-if="item.indicate" :class="$style.indicator" class="_blink"><i class="_indicatorCircle"></i></span>
</div>
</button>
</template>
+
<span v-if="items2 == null || items2.length === 0" tabindex="-1" :class="[$style.none, $style.item]">
<span>{{ i18n.ts.none }}</span>
</span>
@@ -438,6 +473,12 @@ onBeforeUnmount(() => {
}
}
+ &:not(.widthSpecified) {
+ > .menu {
+ max-width: 400px;
+ }
+ }
+
&.big:not(.asDrawer) {
> .menu {
min-width: 230px;
@@ -607,10 +648,19 @@ onBeforeUnmount(() => {
.item_content_text {
max-width: calc(100vw - 4rem);
+}
+
+.item_content_text_title {
text-overflow: ellipsis;
overflow: hidden;
}
+.item_content_text_caption {
+ text-wrap: auto;
+ font-size: 85%;
+ opacity: 0.7;
+}
+
.switchButton {
margin-left: -2px;
--height: 1.35em;
diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue
index ab8bda403b..a729619180 100644
--- a/packages/frontend/src/components/MkPagination.vue
+++ b/packages/frontend/src/components/MkPagination.vue
@@ -24,16 +24,16 @@ SPDX-License-Identifier: AGPL-3.0-only
</slot>
</div>
- <div v-else ref="rootEl">
- <div v-show="pagination.reversed && more" key="_more_" class="_margin">
- <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMoreAhead">
+ <div v-else ref="rootEl" class="_gaps">
+ <div v-show="pagination.reversed && more" key="_more_">
+ <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMoreAhead">
{{ i18n.ts.loadMore }}
</MkButton>
<MkLoading v-else class="loading"/>
</div>
<slot :items="Array.from(items.values())" :fetching="fetching || moreFetching"></slot>
- <div v-show="!pagination.reversed && more" key="_more_" class="_margin">
- <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMore">
+ <div v-show="!pagination.reversed && more" key="_more_">
+ <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMore">
{{ i18n.ts.loadMore }}
</MkButton>
<MkLoading v-else class="loading"/>
@@ -46,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { computed, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, useTemplateRef, watch } from 'vue';
import * as Misskey from 'misskey-js';
import { useDocumentVisibility } from '@@/js/use-document-visibility.js';
-import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@@/js/scroll.js';
+import { onScrollTop, isHeadVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isTailVisible } from '@@/js/scroll.js';
import type { ComputedRef } from 'vue';
import type { MisskeyEntity } from '@/types/date-separated-list.js';
import { misskeyApi } from '@/utility/misskey-api.js';
@@ -74,8 +74,6 @@ export type Paging<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints>
reversed?: boolean;
offsetMode?: boolean;
-
- pageEl?: HTMLElement;
};
type MisskeyEntityMap = Map<string, MisskeyEntity>;
@@ -141,8 +139,7 @@ const {
enableInfiniteScroll,
} = prefer.r;
-const contentEl = computed(() => props.pagination.pageEl ?? rootEl.value);
-const scrollableElement = computed(() => contentEl.value ? getScrollContainer(contentEl.value) : window.document.body);
+const scrollableElement = computed(() => rootEl.value ? getScrollContainer(rootEl.value) : window.document.body);
const visibility = useDocumentVisibility();
@@ -173,13 +170,13 @@ watch(rootEl, () => {
});
});
-watch([backed, contentEl], () => {
+watch([backed, rootEl], () => {
if (!backed.value) {
- if (!contentEl.value) return;
+ if (!rootEl.value) return;
scrollRemove.value = props.pagination.reversed
- ? onScrollBottom(contentEl.value, executeQueue, TOLERANCE)
- : onScrollTop(contentEl.value, (topVisible) => { if (topVisible) executeQueue(); }, TOLERANCE);
+ ? onScrollBottom(rootEl.value, executeQueue, TOLERANCE)
+ : onScrollTop(rootEl.value, (topVisible) => { if (topVisible) executeQueue(); }, TOLERANCE);
} else {
if (scrollRemove.value) scrollRemove.value();
scrollRemove.value = null;
@@ -349,7 +346,7 @@ const appearFetchMoreAhead = async (): Promise<void> => {
fetchMoreAppearTimeout();
};
-const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl.value!, TOLERANCE);
+const isHead = (): boolean => isBackTop.value || (props.pagination.reversed ? isTailVisible : isHeadVisible)(rootEl.value!, TOLERANCE);
watch(visibility, () => {
if (visibility.value === 'hidden') {
@@ -364,7 +361,7 @@ watch(visibility, () => {
timerForSetPause = null;
} else {
isPausingUpdate = false;
- if (isTop()) {
+ if (isHead()) {
executeQueue();
}
}
@@ -376,16 +373,18 @@ watch(visibility, () => {
* ストリーミングから降ってきたアイテムはこれで追加する
* @param item アイテム
*/
-const prepend = (item: MisskeyEntity): void => {
+function prepend(item: MisskeyEntity): void {
if (items.value.size === 0) {
items.value.set(item.id, item);
fetching.value = false;
return;
}
- if (isTop() && !isPausingUpdate) unshiftItems([item]);
+ console.log(isHead(), isPausingUpdate);
+
+ if (isHead() && !isPausingUpdate) unshiftItems([item]);
else prependQueue(item);
-};
+}
/**
* 新着アイテムをitemsの先頭に追加し、displayLimitを適用する
@@ -447,7 +446,7 @@ onDeactivated(() => {
});
function toBottom() {
- scrollToBottom(contentEl.value!);
+ scrollToBottom(rootEl.value!);
}
onBeforeMount(() => {
diff --git a/packages/frontend/src/components/MkPolkadots.vue b/packages/frontend/src/components/MkPolkadots.vue
new file mode 100644
index 0000000000..285c4d0b79
--- /dev/null
+++ b/packages/frontend/src/components/MkPolkadots.vue
@@ -0,0 +1,40 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="[$style.root, accented ? $style.accented : null]"></div>
+</template>
+
+<script lang="ts" setup>
+const props = withDefaults(defineProps<{
+ accented?: boolean;
+}>(), {
+ accented: false,
+});
+</script>
+
+<style lang="scss" module>
+.root {
+ --c: var(--MI_THEME-divider);
+
+ &.accented {
+ --c: var(--MI_THEME-accent);
+ opacity: 0.5;
+ }
+
+ --dot-size: 2px;
+ --gap-size: 40px;
+ --offset: calc(var(--gap-size) / 2);
+
+ height: 200px;
+ margin-bottom: -200px;
+
+ background-image: linear-gradient(transparent 60%, transparent 100%), radial-gradient(var(--c) var(--dot-size), transparent var(--dot-size)), radial-gradient(var(--c) var(--dot-size), transparent var(--dot-size));
+ background-position: 0 0, 0 0, var(--offset) var(--offset);
+ background-size: 100% 100%, var(--gap-size) var(--gap-size), var(--gap-size) var(--gap-size);
+ mask-image: linear-gradient(to bottom, black 0%, transparent 100%);
+ pointer-events: none;
+}
+</style>
diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue
index f20aee0ce3..20dab6f028 100644
--- a/packages/frontend/src/components/MkUrlPreview.vue
+++ b/packages/frontend/src/components/MkUrlPreview.vue
@@ -246,6 +246,7 @@ onUnmounted(() => {
box-shadow: 0 0 0 1px var(--MI_THEME-divider);
border-radius: 8px;
overflow: clip;
+ text-align: left;
&:hover {
text-decoration: none;
diff --git a/packages/frontend/src/local-storage.ts b/packages/frontend/src/local-storage.ts
index 099339fbee..f6d6bbf0fb 100644
--- a/packages/frontend/src/local-storage.ts
+++ b/packages/frontend/src/local-storage.ts
@@ -28,7 +28,7 @@ export type Keys = (
'theme' |
'themeId' |
'customCss' |
- 'message_drafts' |
+ 'chatMessageDrafts' |
'scratchpad' |
'debug' |
'preferences' |
diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts
index d478ece641..894df83721 100644
--- a/packages/frontend/src/navbar.ts
+++ b/packages/frontend/src/navbar.ts
@@ -4,6 +4,7 @@
*/
import { computed, reactive } from 'vue';
+import { ui } from '@@/js/config.js';
import { clearCache } from './utility/clear-cache.js';
import { $i } from '@/i.js';
import { miLocalStorage } from '@/local-storage.js';
@@ -11,7 +12,6 @@ import { openInstanceMenu, openToolsMenu } from '@/ui/_common_/common.js';
import { lookup } from '@/utility/lookup.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
-import { ui } from '@@/js/config.js';
import { unisonReload } from '@/utility/unison-reload.js';
export const navbarItemDef = reactive({
@@ -110,6 +110,12 @@ export const navbarItemDef = reactive({
icon: 'ti ti-device-tv',
to: '/channels',
},
+ chat: {
+ title: i18n.ts.chat,
+ icon: 'ti ti-message',
+ to: '/chat',
+ indicated: computed(() => $i != null && $i.hasUnreadChatMessages),
+ },
achievements: {
title: i18n.ts.achievements,
icon: 'ti ti-medal',
diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue
index 4e9f4edb70..d1e823215a 100644
--- a/packages/frontend/src/pages/admin/roles.editor.vue
+++ b/packages/frontend/src/pages/admin/roles.editor.vue
@@ -160,6 +160,26 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkFolder>
+ <MkFolder v-if="matchQuery([i18n.ts._role._options.canChat, 'canChat'])">
+ <template #label>{{ i18n.ts._role._options.canChat }}</template>
+ <template #suffix>
+ <span v-if="role.policies.canChat.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+ <span v-else>{{ role.policies.canChat.value ? i18n.ts.yes : i18n.ts.no }}</span>
+ <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canChat)"></i></span>
+ </template>
+ <div class="_gaps">
+ <MkSwitch v-model="role.policies.canChat.useDefault" :readonly="readonly">
+ <template #label>{{ i18n.ts._role.useBaseValue }}</template>
+ </MkSwitch>
+ <MkSwitch v-model="role.policies.canChat.value" :disabled="role.policies.canChat.useDefault" :readonly="readonly">
+ <template #label>{{ i18n.ts.enable }}</template>
+ </MkSwitch>
+ <MkRange v-model="role.policies.canChat.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
+ <template #label>{{ i18n.ts._role.priority }}</template>
+ </MkRange>
+ </div>
+ </MkFolder>
+
<MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])">
<template #label>{{ i18n.ts._role._options.mentionMax }}</template>
<template #suffix>
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue
index 0428352350..df4efd1271 100644
--- a/packages/frontend/src/pages/admin/roles.vue
+++ b/packages/frontend/src/pages/admin/roles.vue
@@ -51,6 +51,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</MkFolder>
+ <MkFolder v-if="matchQuery([i18n.ts._role._options.canChat, 'canChat'])">
+ <template #label>{{ i18n.ts._role._options.canChat }}</template>
+ <template #suffix>{{ policies.canChat ? i18n.ts.yes : i18n.ts.no }}</template>
+ <MkSwitch v-model="policies.canChat">
+ <template #label>{{ i18n.ts.enable }}</template>
+ </MkSwitch>
+ </MkFolder>
+
<MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])">
<template #label>{{ i18n.ts._role._options.mentionMax }}</template>
<template #suffix>{{ policies.mentionLimit }}</template>
diff --git a/packages/frontend/src/pages/chat/XMessage.vue b/packages/frontend/src/pages/chat/XMessage.vue
new file mode 100644
index 0000000000..1e7f8e20ea
--- /dev/null
+++ b/packages/frontend/src/pages/chat/XMessage.vue
@@ -0,0 +1,245 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="[$style.root, { [$style.isMe]: isMe }]">
+ <MkAvatar :class="$style.avatar" :user="message.fromUser" :link="!isMe" :preview="false"/>
+ <div :class="$style.body">
+ <MkFukidashi :class="$style.fukidashi" :tail="isMe ? 'right' : 'left'" :accented="isMe">
+ <div v-if="!message.isDeleted" :class="$style.content">
+ <Mfm v-if="message.text" ref="text" class="_selectable" :text="message.text" :i="$i"/>
+ <MkMediaList v-if="message.file" :mediaList="[message.file]" :class="$style.file"/>
+ </div>
+ <div v-else :class="$style.content">
+ <p>{{ i18n.ts.deleted }}</p>
+ </div>
+ </MkFukidashi>
+ <MkUrlPreview v-for="url in urls" :key="url" :url="url" style="margin: 8px 0;"/>
+ <div :class="$style.footer">
+ <button class="_textButton" style="color: currentColor;" @click="showMenu"><i class="ti ti-dots-circle-horizontal"></i></button>
+ <MkTime :class="$style.time" :time="message.createdAt"/>
+ <MkA v-if="isSearchResult && message.toRoomId" :to="`/chat/room/${message.toRoomId}`">{{ message.toRoom.name }}</MkA>
+ <MkA v-if="isSearchResult && message.toUserId && isMe" :to="`/chat/user/${message.toUserId}`">@{{ message.toUser.username }}</MkA>
+ </div>
+ <TransitionGroup
+ :enterActiveClass="prefer.s.animation ? $style.transition_reaction_enterActive : ''"
+ :leaveActiveClass="prefer.s.animation ? $style.transition_reaction_leaveActive : ''"
+ :enterFromClass="prefer.s.animation ? $style.transition_reaction_enterFrom : ''"
+ :leaveToClass="prefer.s.animation ? $style.transition_reaction_leaveTo : ''"
+ :moveClass="prefer.s.animation ? $style.transition_reaction_move : ''"
+ tag="div" :class="$style.reactions"
+ >
+ <div v-for="record in message.reactions" :key="record.reaction + record.user.id" :class="$style.reaction">
+ <MkAvatar :user="record.user" :link="false" :class="$style.reactionAvatar"/>
+ <MkReactionIcon
+ :withTooltip="true"
+ :reaction="record.reaction.replace(/^:(\w+):$/, ':$1@.:')"
+ :noStyle="true"
+ :class="$style.reactionIcon"
+ />
+ </div>
+ </TransitionGroup>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, defineAsyncComponent } from 'vue';
+import * as mfm from 'mfm-js';
+import * as Misskey from 'misskey-js';
+import { url } from '@@/js/config.js';
+import type { MenuItem } from '@/types/menu.js';
+import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
+import MkUrlPreview from '@/components/MkUrlPreview.vue';
+import { ensureSignin } from '@/i.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { i18n } from '@/i18n.js';
+import MkFukidashi from '@/components/MkFukidashi.vue';
+import * as os from '@/os.js';
+import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
+import MkMediaList from '@/components/MkMediaList.vue';
+import { reactionPicker } from '@/utility/reaction-picker.js';
+import * as sound from '@/utility/sound.js';
+import MkReactionIcon from '@/components/MkReactionIcon.vue';
+import { prefer } from '@/preferences.js';
+
+const $i = ensureSignin();
+
+const props = defineProps<{
+ message: Misskey.entities.ChatMessageLite | Misskey.entities.ChatMessage;
+ isSearchResult?: boolean;
+}>();
+
+const isMe = computed(() => props.message.fromUserId === $i.id);
+const urls = computed(() => props.message.text ? extractUrlFromMfm(mfm.parse(props.message.text)) : []);
+
+function react(ev: MouseEvent) {
+ reactionPicker.show(ev.currentTarget ?? ev.target, null, async (reaction) => {
+ sound.playMisskeySfx('reaction');
+
+ misskeyApi('chat/messages/react', {
+ messageId: props.message.id,
+ reaction: reaction,
+ });
+ });
+}
+
+function showMenu(ev: MouseEvent) {
+ const menu: MenuItem[] = [];
+
+ if (!isMe.value) {
+ menu.push({
+ text: i18n.ts.reaction,
+ icon: 'ti ti-mood-plus',
+ action: (ev) => {
+ react(ev);
+ },
+ });
+
+ menu.push({
+ type: 'divider',
+ });
+ }
+
+ menu.push({
+ text: i18n.ts.copyContent,
+ icon: 'ti ti-copy',
+ action: () => {
+ copyToClipboard(props.message.text);
+ },
+ });
+
+ menu.push({
+ type: 'divider',
+ });
+
+ if (isMe.value) {
+ menu.push({
+ text: i18n.ts.delete,
+ icon: 'ti ti-trash',
+ danger: true,
+ action: () => {
+ misskeyApi('chat/messages/delete', {
+ messageId: props.message.id,
+ });
+ },
+ });
+ } else {
+ menu.push({
+ text: i18n.ts.reportAbuse,
+ icon: 'ti ti-exclamation-circle',
+ action: () => {
+ const localUrl = `${url}/chat/messages/${props.message.id}`;
+ const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
+ user: props.message.fromUser,
+ initialComment: `${localUrl}\n-----\n`,
+ }, {
+ closed: () => dispose(),
+ });
+ },
+ });
+ }
+
+ os.popupMenu(menu, ev.currentTarget ?? ev.target);
+}
+</script>
+
+<style lang="scss" module>
+.transition_reaction_move,
+.transition_reaction_enterActive,
+.transition_reaction_leaveActive {
+ transition: opacity 0.2s cubic-bezier(0,.5,.5,1), transform 0.2s cubic-bezier(0,.5,.5,1) !important;
+}
+.transition_reaction_enterFrom,
+.transition_reaction_leaveTo {
+ opacity: 0;
+ transform: scale(0.7);
+}
+.transition_reaction_leaveActive {
+ position: absolute;
+}
+
+.root {
+ position: relative;
+ display: flex;
+
+ &.isMe {
+ flex-direction: row-reverse;
+ text-align: right;
+
+ .content {
+ color: var(--MI_THEME-fgOnAccent);
+ }
+
+ .footer {
+ flex-direction: row-reverse;
+ }
+ }
+}
+
+.avatar {
+ position: sticky;
+ top: calc(16px + var(--MI-stickyTop, 0px));
+ display: block;
+ width: 52px;
+ height: 52px;
+}
+
+.body {
+ margin: 0 12px;
+}
+
+.content {
+ overflow: clip;
+ overflow-wrap: break-word;
+ word-break: break-word;
+}
+
+.file {
+}
+
+.footer {
+ display: flex;
+ flex-direction: row;
+ gap: 0.5em;
+ margin-top: 4px;
+ font-size: 75%;
+}
+
+.time {
+ opacity: 0.5;
+}
+
+.reactions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ margin-top: 8px;
+
+ &:empty {
+ display: none;
+ }
+}
+
+.reaction {
+ display: flex;
+ align-items: center;
+ border: solid 1px var(--MI_THEME-divider);
+ border-radius: 999px;
+ padding: 8px;
+}
+
+.reactionAvatar {
+ width: 24px;
+ height: 24px;
+ margin-right: 8px;
+}
+
+.reactionIcon {
+ width: 24px;
+ height: 24px;
+}
+</style>
diff --git a/packages/frontend/src/pages/chat/XRoom.vue b/packages/frontend/src/pages/chat/XRoom.vue
new file mode 100644
index 0000000000..b063a0cdd1
--- /dev/null
+++ b/packages/frontend/src/pages/chat/XRoom.vue
@@ -0,0 +1,41 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkA :to="`/chat/room/${room.id}`" class="_panel _gaps_s" :class="$style.root">
+ <div :class="$style.header">
+ <div style="font-weight: bold;">{{ room.name }}</div>
+ <MkAvatar :user="room.owner" :link="false" :class="$style.headerAvatar"/>
+ </div>
+ <hr>
+ <div>{{ room.description }}</div>
+</MkA>
+</template>
+
+<script lang="ts" setup>
+import * as Misskey from 'misskey-js';
+
+const props = defineProps<{
+ room: Misskey.entities.ChatRoom;
+}>();
+
+</script>
+
+<style lang="scss" module>
+.root {
+ padding: 16px;
+}
+
+.header {
+ display: flex;
+ align-items: center;
+}
+
+.headerAvatar {
+ width: 30px;
+ height: 30px;
+ margin-left: auto;
+}
+</style>
diff --git a/packages/frontend/src/pages/chat/home.home.vue b/packages/frontend/src/pages/chat/home.home.vue
new file mode 100644
index 0000000000..1d0605136c
--- /dev/null
+++ b/packages/frontend/src/pages/chat/home.home.vue
@@ -0,0 +1,252 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div class="_gaps">
+ <MkButton primary gradate rounded :class="$style.start" @click="start"><i class="ti ti-plus"></i> {{ i18n.ts.startChat }}</MkButton>
+
+ <MkAd :prefer="['horizontal', 'horizontal-big']"/>
+
+ <MkInput
+ v-model="searchQuery"
+ :placeholder="i18n.ts._chat.searchMessages"
+ type="search"
+ >
+ <template #prefix><i class="ti ti-search"></i></template>
+ </MkInput>
+
+ <MkButton v-if="searchQuery.length > 0" primary rounded @click="search">{{ i18n.ts.search }}</MkButton>
+
+ <MkFoldableSection v-if="searched">
+ <template #header>{{ i18n.ts.searchResult }}</template>
+
+ <div class="_gaps_s">
+ <div v-for="message in searchResults" :key="message.id" :class="$style.searchResultItem">
+ <XMessage :message="message" :isSearchResult="true"/>
+ </div>
+ </div>
+ </MkFoldableSection>
+
+ <MkFoldableSection>
+ <template #header>{{ i18n.ts._chat.history }}</template>
+
+ <div v-if="history.length > 0" class="_gaps_s">
+ <MkA
+ v-for="item in history"
+ :key="item.id"
+ :class="[$style.message, { [$style.isMe]: item.isMe, [$style.isRead]: item.message.isRead }]"
+ class="_panel"
+ :to="item.message.toRoomId ? `/chat/room/${item.message.toRoomId}` : `/chat/user/${item.other!.id}`"
+ >
+ <MkAvatar v-if="item.other" :class="$style.messageAvatar" :user="item.other" indicator :preview="false"/>
+ <div :class="$style.messageBody">
+ <header v-if="item.message.toRoom" :class="$style.messageHeader">
+ <span :class="$style.messageHeaderName">{{ item.message.toRoom.name }}</span>
+ <MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/>
+ </header>
+ <header v-else :class="$style.messageHeader">
+ <MkUserName :class="$style.messageHeaderName" :user="item.other!"/>
+ <MkAcct :class="$style.messageHeaderUsername" :user="item.other!"/>
+ <MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/>
+ </header>
+ <div :class="$style.messageBodyText"><span v-if="item.isMe" :class="$style.youSaid">{{ i18n.ts.you }}:</span>{{ item.message.text }}</div>
+ </div>
+ </MkA>
+ </div>
+ <div v-if="!fetching && history.length == 0" class="_fullinfo">
+ <div>{{ i18n.ts._chat.noHistory }}</div>
+ </div>
+ <MkLoading v-if="fetching"/>
+ </MkFoldableSection>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import XMessage from './XMessage.vue';
+import MkButton from '@/components/MkButton.vue';
+import { i18n } from '@/i18n.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { ensureSignin } from '@/i.js';
+import { useRouter } from '@/router.js';
+import * as os from '@/os.js';
+import { updateCurrentAccountPartial } from '@/accounts.js';
+import MkInput from '@/components/MkInput.vue';
+import MkFoldableSection from '@/components/MkFoldableSection.vue';
+
+const $i = ensureSignin();
+
+const router = useRouter();
+
+const fetching = ref(true);
+const history = ref<{
+ id: string;
+ message: Misskey.entities.ChatMessage;
+ other: Misskey.entities.ChatMessage['fromUser'] | Misskey.entities.ChatMessage['toUser'] | null;
+ isMe: boolean;
+}[]>([]);
+
+const searchQuery = ref('');
+const searched = ref(false);
+const searchResults = ref<Misskey.entities.ChatMessage[]>([]);
+
+function start(ev: MouseEvent) {
+ os.popupMenu([{
+ text: i18n.ts._chat.individualChat,
+ caption: i18n.ts._chat.individualChat_description,
+ icon: 'ti ti-user',
+ action: () => { startUser(); },
+ }, { type: 'divider' }, {
+ type: 'parent',
+ text: i18n.ts._chat.roomChat,
+ caption: i18n.ts._chat.roomChat_description,
+ icon: 'ti ti-users-group',
+ children: [{
+ text: i18n.ts._chat.createRoom,
+ icon: 'ti ti-plus',
+ action: () => { createRoom(); },
+ }],
+ }], ev.currentTarget ?? ev.target);
+}
+
+async function startUser() {
+ os.selectUser().then(user => {
+ router.push(`/chat/user/${user.id}`);
+ });
+}
+
+async function createRoom() {
+ const { canceled, result } = await os.inputText({
+ title: i18n.ts.name,
+ minLength: 1,
+ });
+ if (canceled) return;
+
+ const room = await misskeyApi('chat/rooms/create', {
+ name: result,
+ });
+
+ router.push(`/chat/room/${room.id}`);
+}
+
+async function search() {
+ const res = await misskeyApi('chat/messages/search', {
+ query: searchQuery.value,
+ });
+
+ searchResults.value = res;
+ searched.value = true;
+}
+
+async function fetchHistory() {
+ fetching.value = true;
+
+ const [userMessages, roomMessages] = await Promise.all([
+ misskeyApi('chat/history', { room: false }),
+ misskeyApi('chat/history', { room: true }),
+ ]);
+
+ history.value = [...userMessages, ...roomMessages]
+ .toSorted((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
+ .map(m => ({
+ id: m.id,
+ message: m,
+ other: m.room == null ? (m.fromUserId === $i.id ? m.toUser : m.fromUser) : null,
+ isMe: m.fromUserId === $i.id,
+ }));
+
+ fetching.value = false;
+
+ updateCurrentAccountPartial({ hasUnreadChatMessages: false });
+}
+
+onMounted(() => {
+ fetchHistory();
+});
+</script>
+
+<style lang="scss" module>
+.start {
+ margin: 0 auto;
+}
+
+.message {
+ position: relative;
+ display: flex;
+ padding: 16px 24px;
+
+ &.isRead,
+ &.isMe {
+ opacity: 0.8;
+ }
+
+ &:not(.isMe):not(.isRead) {
+ &::before {
+ content: '';
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ width: 8px;
+ height: 8px;
+ border-radius: 100%;
+ background-color: var(--MI_THEME-accent);
+ }
+ }
+}
+
+.messageAvatar {
+ width: 50px;
+ height: 50px;
+ margin: 0 16px 0 0;
+}
+
+.messageBody {
+ flex: 1;
+ min-width: 0;
+}
+
+.messageHeader {
+ display: flex;
+ align-items: center;
+ margin-bottom: 2px;
+ white-space: nowrap;
+ overflow: clip;
+}
+
+.messageHeaderName {
+ margin: 0;
+ padding: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-size: 1em;
+ font-weight: bold;
+}
+
+.messageHeaderUsername {
+ margin: 0 8px;
+}
+
+.messageHeaderTime {
+ margin-left: auto;
+}
+
+.messageBodyText {
+ overflow: hidden;
+ overflow-wrap: break-word;
+ font-size: 1.1em;
+}
+
+.youSaid {
+ font-weight: bold;
+ margin-right: 0.5em;
+}
+
+.searchResultItem {
+ padding: 12px;
+ border: solid 1px var(--MI_THEME-divider);
+ border-radius: 12px;
+}
+</style>
diff --git a/packages/frontend/src/pages/chat/home.invitations.vue b/packages/frontend/src/pages/chat/home.invitations.vue
new file mode 100644
index 0000000000..4c3c0b282e
--- /dev/null
+++ b/packages/frontend/src/pages/chat/home.invitations.vue
@@ -0,0 +1,98 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div class="_gaps">
+ <div v-if="invitations.length > 0" class="_gaps_s">
+ <MkFolder v-for="invitation in invitations" :key="invitation.id" :defaultOpen="true">
+ <template #icon><i class="ti ti-users-group"></i></template>
+ <template #label>{{ invitation.room.name }}</template>
+ <template #suffix><MkTime :time="invitation.createdAt"/></template>
+ <template #footer>
+ <div class="_buttons">
+ <MkButton primary @click="join(invitation)"><i class="ti ti-plus"></i> {{ i18n.ts._chat.join }}</MkButton>
+ <MkButton danger @click="ignore(invitation)"><i class="ti ti-x"></i> {{ i18n.ts._chat.ignore }}</MkButton>
+ </div>
+ </template>
+
+ <div :class="$style.invitationBody">
+ <MkAvatar :user="invitation.room.owner" :class="$style.invitationBodyAvatar" link/>
+ <div style="flex: 1;" class="_gaps_s">
+ <MkUserName :user="invitation.room.owner"/>
+ <hr>
+ <div>{{ invitation.room.description === '' ? i18n.ts.noDescription : invitation.room.description }}</div>
+ </div>
+ </div>
+ </MkFolder>
+ </div>
+ <div v-if="!fetching && invitations.length == 0" class="_fullinfo">
+ <div>{{ i18n.ts._chat.noInvitations }}</div>
+ </div>
+ <MkLoading v-if="fetching"/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import MkButton from '@/components/MkButton.vue';
+import { i18n } from '@/i18n.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { ensureSignin } from '@/i.js';
+import { useRouter } from '@/router.js';
+import * as os from '@/os.js';
+import MkFolder from '@/components/MkFolder.vue';
+
+const $i = ensureSignin();
+
+const router = useRouter();
+
+const fetching = ref(true);
+const invitations = ref<Misskey.entities.ChatRoomInvitation[]>([]);
+
+async function fetchInvitations() {
+ fetching.value = true;
+
+ const res = await misskeyApi('chat/rooms/invitations/inbox', {
+ });
+
+ invitations.value = res;
+
+ fetching.value = false;
+}
+
+async function join(invitation: Misskey.entities.ChatRoomInvitation) {
+ await misskeyApi('chat/rooms/join', {
+ roomId: invitation.room.id,
+ });
+
+ router.push(`/chat/room/${invitation.room.id}`);
+}
+
+async function ignore(invitation: Misskey.entities.ChatRoomInvitation) {
+ await misskeyApi('chat/rooms/invitations/ignore', {
+ roomId: invitation.room.id,
+ });
+
+ invitations.value = invitations.value.filter(i => i.id !== invitation.id);
+}
+
+onMounted(() => {
+ fetchInvitations();
+});
+</script>
+
+<style lang="scss" module>
+.invitationBody {
+ display: flex;
+ align-items: center;
+}
+
+.invitationBodyAvatar {
+ margin-right: 12px;
+ width: 45px;
+ height: 45px;
+}
+</style>
diff --git a/packages/frontend/src/pages/chat/home.joiningRooms.vue b/packages/frontend/src/pages/chat/home.joiningRooms.vue
new file mode 100644
index 0000000000..63e4d2adf8
--- /dev/null
+++ b/packages/frontend/src/pages/chat/home.joiningRooms.vue
@@ -0,0 +1,54 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div class="_gaps">
+ <div v-if="memberships.length > 0" class="_gaps_s">
+ <XRoom v-for="membership in memberships" :key="membership.id" :room="membership.room"/>
+ </div>
+ <div v-if="!fetching && memberships.length == 0" class="_fullinfo">
+ <div>{{ i18n.ts._chat.noRooms }}</div>
+ </div>
+ <MkLoading v-if="fetching"/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import XRoom from './XRoom.vue';
+import MkButton from '@/components/MkButton.vue';
+import { i18n } from '@/i18n.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { ensureSignin } from '@/i.js';
+import { useRouter } from '@/router.js';
+import * as os from '@/os.js';
+
+const $i = ensureSignin();
+
+const router = useRouter();
+
+const fetching = ref(true);
+const memberships = ref<Misskey.entities.ChatRoomMembership[]>([]);
+
+async function fetchRooms() {
+ fetching.value = true;
+
+ const res = await misskeyApi('chat/rooms/joining', {
+ });
+
+ memberships.value = res;
+
+ fetching.value = false;
+}
+
+onMounted(() => {
+ fetchRooms();
+});
+</script>
+
+<style lang="scss" module>
+
+</style>
diff --git a/packages/frontend/src/pages/chat/home.ownedRooms.vue b/packages/frontend/src/pages/chat/home.ownedRooms.vue
new file mode 100644
index 0000000000..b0449fb373
--- /dev/null
+++ b/packages/frontend/src/pages/chat/home.ownedRooms.vue
@@ -0,0 +1,54 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div class="_gaps">
+ <div v-if="rooms.length > 0" class="_gaps_s">
+ <XRoom v-for="room in rooms" :key="room.id" :room="room"/>
+ </div>
+ <div v-if="!fetching && rooms.length == 0" class="_fullinfo">
+ <div>{{ i18n.ts._chat.noRooms }}</div>
+ </div>
+ <MkLoading v-if="fetching"/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import XRoom from './XRoom.vue';
+import MkButton from '@/components/MkButton.vue';
+import { i18n } from '@/i18n.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { ensureSignin } from '@/i.js';
+import { useRouter } from '@/router.js';
+import * as os from '@/os.js';
+
+const $i = ensureSignin();
+
+const router = useRouter();
+
+const fetching = ref(true);
+const rooms = ref<Misskey.entities.ChatRoom[]>([]);
+
+async function fetchRooms() {
+ fetching.value = true;
+
+ const res = await misskeyApi('chat/rooms/owned', {
+ });
+
+ rooms.value = res;
+
+ fetching.value = false;
+}
+
+onMounted(() => {
+ fetchRooms();
+});
+</script>
+
+<style lang="scss" module>
+
+</style>
diff --git a/packages/frontend/src/pages/chat/home.vue b/packages/frontend/src/pages/chat/home.vue
new file mode 100644
index 0000000000..c2b272a42d
--- /dev/null
+++ b/packages/frontend/src/pages/chat/home.vue
@@ -0,0 +1,60 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
+ <MkPolkadots v-if="tab === 'home'" accented/>
+ <MkSpacer :contentMax="700">
+ <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
+ <XHome v-if="tab === 'home'"/>
+ <XInvitations v-else-if="tab === 'invitations'"/>
+ <XJoiningRooms v-else-if="tab === 'joiningRooms'"/>
+ <XOwnedRooms v-else-if="tab === 'ownedRooms'"/>
+ </MkHorizontalSwipe>
+ </MkSpacer>
+</PageWithHeader>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, ref } from 'vue';
+import XHome from './home.home.vue';
+import XInvitations from './home.invitations.vue';
+import XJoiningRooms from './home.joiningRooms.vue';
+import XOwnedRooms from './home.ownedRooms.vue';
+import { i18n } from '@/i18n.js';
+import { definePage } from '@/page.js';
+import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
+import MkPolkadots from '@/components/MkPolkadots.vue';
+
+const tab = ref('home');
+
+const headerActions = computed(() => []);
+
+const headerTabs = computed(() => [{
+ key: 'home',
+ title: i18n.ts._chat.home,
+ icon: 'ti ti-home',
+}, {
+ key: 'invitations',
+ title: i18n.ts._chat.invitations,
+ icon: 'ti ti-ticket',
+}, {
+ key: 'joiningRooms',
+ title: i18n.ts._chat.joiningRooms,
+ icon: 'ti ti-users-group',
+}, {
+ key: 'ownedRooms',
+ title: i18n.ts._chat.yourRooms,
+ icon: 'ti ti-settings',
+}]);
+
+definePage(() => ({
+ title: i18n.ts.chat + ' (beta)',
+ icon: 'ti ti-message',
+}));
+</script>
+
+<style lang="scss" module>
+</style>
diff --git a/packages/frontend/src/pages/chat/message.vue b/packages/frontend/src/pages/chat/message.vue
new file mode 100644
index 0000000000..be8be7e5d1
--- /dev/null
+++ b/packages/frontend/src/pages/chat/message.vue
@@ -0,0 +1,55 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<PageWithHeader>
+ <MkSpacer :contentMax="700">
+ <div v-if="initializing">
+ <MkLoading/>
+ </div>
+ <div v-else>
+ <XMessage :message="message"/>
+ </div>
+ </MkSpacer>
+</PageWithHeader>
+</template>
+
+<script lang="ts" setup>
+import { ref, useTemplateRef, computed, watch, onMounted, nextTick, onBeforeUnmount, onDeactivated, onActivated } from 'vue';
+import * as Misskey from 'misskey-js';
+import XMessage from './XMessage.vue';
+import * as os from '@/os.js';
+import { useStream } from '@/stream.js';
+import { i18n } from '@/i18n.js';
+import { ensureSignin } from '@/i.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { definePage } from '@/page.js';
+import MkButton from '@/components/MkButton.vue';
+
+const props = defineProps<{
+ messageId?: string;
+}>();
+
+const initializing = ref(true);
+const message = ref<Misskey.entities.ChatMessage>();
+
+async function initialize() {
+ initializing.value = true;
+
+ message.value = await misskeyApi('chat/messages/show', {
+ messageId: props.messageId,
+ });
+
+ initializing.value = false;
+}
+
+onMounted(() => {
+ initialize();
+});
+
+definePage({
+ title: i18n.ts.chat,
+});
+</script>
diff --git a/packages/frontend/src/pages/chat/room.form.vue b/packages/frontend/src/pages/chat/room.form.vue
new file mode 100644
index 0000000000..aba9d6061f
--- /dev/null
+++ b/packages/frontend/src/pages/chat/room.form.vue
@@ -0,0 +1,333 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div
+ :class="$style.root"
+ @dragover.stop="onDragover"
+ @drop.stop="onDrop"
+>
+ <textarea
+ ref="textareaEl"
+ v-model="text"
+ :class="$style.textarea"
+ class="_acrylic"
+ :placeholder="i18n.ts.inputMessageHere"
+ :readonly="textareaReadOnly"
+ @keydown="onKeydown"
+ @paste="onPaste"
+ ></textarea>
+ <footer :class="$style.footer">
+ <div v-if="file" :class="$style.file" @click="file = null">{{ file.name }}</div>
+ <div :class="$style.buttons">
+ <button class="_button" :class="$style.button" @click="chooseFile"><i class="ti ti-photo-plus"></i></button>
+ <button class="_button" :class="$style.button" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button>
+ <button class="_button" :class="[$style.button, $style.send]" :disabled="!canSend || sending" :title="i18n.ts.send" @click="send">
+ <template v-if="!sending"><i class="ti ti-send"></i></template><template v-if="sending"><MkLoading :em="true"/></template>
+ </button>
+ </div>
+ </footer>
+ <input ref="fileEl" style="display: none;" type="file" @change="onChangeFile"/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, watch, ref, shallowRef, computed, nextTick, readonly } from 'vue';
+import * as Misskey from 'misskey-js';
+//import insertTextAtCursor from 'insert-text-at-cursor';
+import { throttle } from 'throttle-debounce';
+import { formatTimeString } from '@/utility/format-time-string.js';
+import { selectFile } from '@/utility/select-file.js';
+import * as os from '@/os.js';
+import { useStream } from '@/stream.js';
+import { i18n } from '@/i18n.js';
+import { uploadFile } from '@/utility/upload.js';
+import { miLocalStorage } from '@/local-storage.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { prefer } from '@/preferences.js';
+import { Autocomplete } from '@/utility/autocomplete.js';
+import { emojiPicker } from '@/utility/emoji-picker.js';
+
+const props = defineProps<{
+ user?: Misskey.entities.UserDetailed | null;
+ room?: Misskey.entities.ChatRoom | null;
+}>();
+
+const textareaEl = shallowRef<HTMLTextAreaElement>();
+const fileEl = shallowRef<HTMLInputElement>();
+
+const text = ref<string>('');
+const file = ref<Misskey.entities.DriveFile | null>(null);
+const sending = ref(false);
+const textareaReadOnly = ref(false);
+
+const canSend = computed(() => (text.value != null && text.value !== '') || file.value != null);
+
+function getDraftKey() {
+ return props.user ? 'user:' + props.user.id : 'room:' + props.room?.id;
+}
+
+watch([text, file], saveDraft);
+
+async function onPaste(ev: ClipboardEvent) {
+ if (!ev.clipboardData) return;
+
+ const pastedFileName = 'yyyy-MM-dd HH-mm-ss [{{number}}]';
+
+ const clipboardData = ev.clipboardData;
+ const items = clipboardData.items;
+
+ if (items.length === 1) {
+ if (items[0].kind === 'file') {
+ const pastedFile = items[0].getAsFile();
+ if (!pastedFile) return;
+ const lio = pastedFile.name.lastIndexOf('.');
+ const ext = lio >= 0 ? pastedFile.name.slice(lio) : '';
+ const formatted = formatTimeString(new Date(pastedFile.lastModified), pastedFileName).replace(/{{number}}/g, '1') + ext;
+ if (formatted) upload(pastedFile, formatted);
+ }
+ } else {
+ if (items[0].kind === 'file') {
+ os.alert({
+ type: 'error',
+ text: i18n.ts.onlyOneFileCanBeAttached,
+ });
+ }
+ }
+}
+
+function onDragover(ev: DragEvent) {
+ if (!ev.dataTransfer) return;
+
+ const isFile = ev.dataTransfer.items[0].kind === 'file';
+ const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
+ if (isFile || isDriveFile) {
+ ev.preventDefault();
+ switch (ev.dataTransfer.effectAllowed) {
+ case 'all':
+ case 'uninitialized':
+ case 'copy':
+ case 'copyLink':
+ case 'copyMove':
+ ev.dataTransfer.dropEffect = 'copy';
+ break;
+ case 'linkMove':
+ case 'move':
+ ev.dataTransfer.dropEffect = 'move';
+ break;
+ default:
+ ev.dataTransfer.dropEffect = 'none';
+ break;
+ }
+ }
+}
+
+function onDrop(ev: DragEvent): void {
+ if (!ev.dataTransfer) return;
+
+ // ファイルだったら
+ if (ev.dataTransfer.files.length === 1) {
+ ev.preventDefault();
+ upload(ev.dataTransfer.files[0]);
+ return;
+ } else if (ev.dataTransfer.files.length > 1) {
+ ev.preventDefault();
+ os.alert({
+ type: 'error',
+ text: i18n.ts.onlyOneFileCanBeAttached,
+ });
+ return;
+ }
+
+ //#region ドライブのファイル
+ const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
+ if (driveFile != null && driveFile !== '') {
+ file.value = JSON.parse(driveFile);
+ ev.preventDefault();
+ }
+ //#endregion
+}
+
+function onKeydown(ev: KeyboardEvent) {
+ if ((ev.key === 'Enter') && (ev.ctrlKey || ev.metaKey)) {
+ send();
+ }
+}
+
+function chooseFile(ev: MouseEvent) {
+ selectFile(ev.currentTarget ?? ev.target, i18n.ts.selectFile).then(selectedFile => {
+ file.value = selectedFile;
+ });
+}
+
+function onChangeFile() {
+ if (fileEl.value.files![0]) upload(fileEl.value.files[0]);
+}
+
+function upload(fileToUpload: File, name?: string) {
+ uploadFile(fileToUpload, prefer.s.uploadFolder, name).then(res => {
+ file.value = res;
+ });
+}
+
+function send() {
+ if (!canSend.value) return;
+
+ sending.value = true;
+
+ if (props.user) {
+ misskeyApi('chat/messages/create-to-user', {
+ toUserId: props.user.id,
+ text: text.value ? text.value : undefined,
+ fileId: file.value ? file.value.id : undefined,
+ }).then(message => {
+ clear();
+ }).catch(err => {
+ console.error(err);
+ }).then(() => {
+ sending.value = false;
+ });
+ } else if (props.room) {
+ misskeyApi('chat/messages/create-to-room', {
+ toRoomId: props.room.id,
+ text: text.value ? text.value : undefined,
+ fileId: file.value ? file.value.id : undefined,
+ }).then(message => {
+ clear();
+ }).catch(err => {
+ console.error(err);
+ }).then(() => {
+ sending.value = false;
+ });
+ }
+}
+
+function clear() {
+ text.value = '';
+ file.value = null;
+ deleteDraft();
+}
+
+function saveDraft() {
+ const drafts = JSON.parse(miLocalStorage.getItem('chatMessageDrafts') || '{}');
+
+ drafts[getDraftKey()] = {
+ updatedAt: new Date(),
+ data: {
+ text: text.value,
+ file: file.value,
+ },
+ };
+
+ miLocalStorage.setItem('chatMessageDrafts', JSON.stringify(drafts));
+}
+
+function deleteDraft() {
+ const drafts = JSON.parse(miLocalStorage.getItem('chatMessageDrafts') || '{}');
+
+ delete drafts[getDraftKey()];
+
+ miLocalStorage.setItem('chatMessageDrafts', JSON.stringify(drafts));
+}
+
+async function insertEmoji(ev: MouseEvent) {
+ textareaReadOnly.value = true;
+ const target = ev.currentTarget ?? ev.target;
+ if (target == null) return;
+
+ // emojiPickerはダイアログが閉じずにtextareaとやりとりするので、
+ // focustrapをかけているとinsertTextAtCursorが効かない
+ // そのため、投稿フォームのテキストに直接注入する
+ // See: https://github.com/misskey-dev/misskey/pull/14282
+ // https://github.com/misskey-dev/misskey/issues/14274
+
+ let pos = textareaEl.value?.selectionStart ?? 0;
+ let posEnd = textareaEl.value?.selectionEnd ?? text.value.length;
+ emojiPicker.show(
+ target as HTMLElement,
+ emoji => {
+ const textBefore = text.value.substring(0, pos);
+ const textAfter = text.value.substring(posEnd);
+ text.value = textBefore + emoji + textAfter;
+ pos += emoji.length;
+ posEnd += emoji.length;
+ },
+ () => {
+ textareaReadOnly.value = false;
+ nextTick(() => focus());
+ },
+ );
+}
+
+onMounted(() => {
+ // TODO: detach when unmount
+ new Autocomplete(textareaEl.value, text);
+
+ // 書きかけの投稿を復元
+ const draft = JSON.parse(miLocalStorage.getItem('chatMessageDrafts') || '{}')[getDraftKey()];
+ if (draft) {
+ text.value = draft.data.text;
+ file.value = draft.data.file;
+ }
+});
+</script>
+
+<style lang="scss" module>
+.root {
+ position: relative;
+ border-bottom: none;
+ border-radius: 14px 14px 0 0;
+ overflow: clip;
+}
+
+.textarea {
+ cursor: auto;
+ display: block;
+ width: 100%;
+ min-width: 100%;
+ max-width: 100%;
+ min-height: 80px;
+ margin: 0;
+ padding: 16px 16px 0 16px;
+ resize: none;
+ font-size: 1em;
+ font-family: inherit;
+ outline: none;
+ border: none;
+ border-radius: 0;
+ box-shadow: none;
+ box-sizing: border-box;
+ color: var(--MI_THEME-fg);
+ field-sizing: content;
+}
+
+.footer {
+ position: sticky;
+ bottom: 0;
+ background: var(--MI_THEME-panel);
+}
+
+.file {
+ padding: 8px;
+ cursor: pointer;
+}
+
+.buttons {
+ display: flex;
+}
+
+.button {
+ height: 50px;
+ aspect-ratio: 1;
+
+ &:hover {
+ color: var(--MI_THEME-accent);
+ }
+}
+.send {
+ margin-left: auto;
+ color: var(--MI_THEME-accent);
+}
+</style>
diff --git a/packages/frontend/src/pages/chat/room.info.vue b/packages/frontend/src/pages/chat/room.info.vue
new file mode 100644
index 0000000000..7d38d07b3a
--- /dev/null
+++ b/packages/frontend/src/pages/chat/room.info.vue
@@ -0,0 +1,87 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div class="_gaps">
+ <MkInput v-model="name_" :disabled="!isOwner">
+ <template #label>{{ i18n.ts.name }}</template>
+ </MkInput>
+
+ <MkTextarea v-model="description_" :disabled="!isOwner">
+ <template #label>{{ i18n.ts.description }}</template>
+ </MkTextarea>
+
+ <MkButton v-if="isOwner" primary @click="save">{{ i18n.ts.save }}</MkButton>
+
+ <hr>
+
+ <MkSwitch v-if="!isOwner" v-model="isMuted">
+ <template #label>{{ i18n.ts._chat.muteThisRoom }}</template>
+ </MkSwitch>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, ref, watch } from 'vue';
+import * as Misskey from 'misskey-js';
+import MkButton from '@/components/MkButton.vue';
+import { i18n } from '@/i18n.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import * as os from '@/os.js';
+import { ensureSignin } from '@/i.js';
+import MkInput from '@/components/MkInput.vue';
+import MkTextarea from '@/components/MkTextarea.vue';
+import MkSwitch from '@/components/MkSwitch.vue';
+
+const $i = ensureSignin();
+
+const props = defineProps<{
+ room: Misskey.entities.ChatRoom;
+}>();
+
+const isOwner = computed(() => {
+ return props.room.ownerId === $i.id;
+});
+
+const name_ = ref(props.room.name);
+const description_ = ref(props.room.description);
+
+function save() {
+ os.apiWithDialog('chat/rooms/update', {
+ roomId: props.room.id,
+ name: name_.value,
+ description: description_.value,
+ });
+}
+
+const isMuted = ref(props.room.isMuted);
+
+watch(isMuted, async () => {
+ await os.apiWithDialog('chat/rooms/mute', {
+ roomId: props.room.id,
+ mute: isMuted.value,
+ });
+});
+
+onMounted(async () => {
+
+});
+</script>
+
+<style lang="scss" module>
+.membership {
+ display: flex;
+}
+
+.membershipBody {
+ flex: 1;
+ min-width: 0;
+ margin-right: 8px;
+
+ &:hover {
+ text-decoration: none;
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/chat/room.members.vue b/packages/frontend/src/pages/chat/room.members.vue
new file mode 100644
index 0000000000..d20216a81c
--- /dev/null
+++ b/packages/frontend/src/pages/chat/room.members.vue
@@ -0,0 +1,73 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div class="_gaps">
+ <MkButton v-if="isOwner" primary rounded style="margin: 0 auto;" @click="emit('inviteUser')"><i class="ti ti-plus"></i> {{ i18n.ts._chat.inviteUser }}</MkButton>
+
+ <MkA :class="$style.membershipBody" :to="`${userPage(room.owner)}`">
+ <MkUserCardMini :user="room.owner"/>
+ </MkA>
+
+ <hr>
+
+ <div v-for="membership in memberships" :key="membership.id" :class="$style.membership">
+ <MkA :class="$style.membershipBody" :to="`${userPage(membership.user)}`">
+ <MkUserCardMini :user="membership.user"/>
+ </MkA>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import MkButton from '@/components/MkButton.vue';
+import { i18n } from '@/i18n.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import * as os from '@/os.js';
+import MkUserCardMini from '@/components/MkUserCardMini.vue';
+import { userPage } from '@/filters/user.js';
+import { ensureSignin } from '@/i.js';
+
+const $i = ensureSignin();
+
+const props = defineProps<{
+ room: Misskey.entities.ChatRoom;
+}>();
+
+const emit = defineEmits<{
+ (ev: 'inviteUser'): void,
+}>();
+
+const isOwner = computed(() => {
+ return props.room.ownerId === $i.id;
+});
+
+const memberships = ref<Misskey.entities.ChatRoomMembership[]>([]);
+
+onMounted(async () => {
+ memberships.value = await misskeyApi('chat/rooms/members', {
+ roomId: props.room.id,
+ limit: 50,
+ });
+});
+</script>
+
+<style lang="scss" module>
+.membership {
+ display: flex;
+}
+
+.membershipBody {
+ flex: 1;
+ min-width: 0;
+ margin-right: 8px;
+
+ &:hover {
+ text-decoration: none;
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/chat/room.search.vue b/packages/frontend/src/pages/chat/room.search.vue
new file mode 100644
index 0000000000..de5e7156ca
--- /dev/null
+++ b/packages/frontend/src/pages/chat/room.search.vue
@@ -0,0 +1,68 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div class="_gaps">
+ <MkInput
+ v-model="searchQuery"
+ :placeholder="i18n.ts._chat.searchMessages"
+ type="search"
+ >
+ <template #prefix><i class="ti ti-search"></i></template>
+ </MkInput>
+
+ <MkButton v-if="searchQuery.length > 0" primary rounded @click="search">{{ i18n.ts.search }}</MkButton>
+
+ <MkFoldableSection v-if="searched">
+ <template #header>{{ i18n.ts.searchResult }}</template>
+
+ <div class="_gaps_s">
+ <div v-for="message in searchResults" :key="message.id" :class="$style.searchResultItem">
+ <XMessage :message="message" :user="message.fromUser" :isSearchResult="true"/>
+ </div>
+ </div>
+ </MkFoldableSection>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, ref } from 'vue';
+import * as Misskey from 'misskey-js';
+import XMessage from './XMessage.vue';
+import MkButton from '@/components/MkButton.vue';
+import { i18n } from '@/i18n.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import * as os from '@/os.js';
+import MkInput from '@/components/MkInput.vue';
+import MkFoldableSection from '@/components/MkFoldableSection.vue';
+
+const props = defineProps<{
+ userId?: string;
+ roomId?: string;
+}>();
+
+const searchQuery = ref('');
+const searched = ref(false);
+const searchResults = ref<Misskey.entities.ChatMessage[]>([]);
+
+async function search() {
+ const res = await misskeyApi('chat/messages/search', {
+ query: searchQuery.value,
+ roomId: props.roomId,
+ userId: props.userId,
+ });
+
+ searchResults.value = res;
+ searched.value = true;
+}
+</script>
+
+<style lang="scss" module>
+.searchResultItem {
+ padding: 12px;
+ border: solid 1px var(--MI_THEME-divider);
+ border-radius: 12px;
+}
+</style>
diff --git a/packages/frontend/src/pages/chat/room.vue b/packages/frontend/src/pages/chat/room.vue
new file mode 100644
index 0000000000..15e9f43db2
--- /dev/null
+++ b/packages/frontend/src/pages/chat/room.vue
@@ -0,0 +1,426 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<PageWithHeader v-model:tab="tab" :reversed="tab === 'chat'" :tabs="headerTabs" :actions="headerActions">
+ <MkSpacer v-if="tab === 'chat'" :contentMax="700">
+ <div v-if="initializing">
+ <MkLoading/>
+ </div>
+
+ <div v-else-if="messages.length === 0">
+ <div class="_gaps" style="text-align: center;">
+ <div>{{ i18n.ts._chat.noMessagesYet }}</div>
+ <template v-if="user">
+ <div v-if="user.chatScope === 'followers'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromFollowers }}</div>
+ <div v-else-if="user.chatScope === 'following'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromFollowing }}</div>
+ <div v-else-if="user.chatScope === 'mutual'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromMutualFollowing }}</div>
+ <div v-else>{{ i18n.ts._chat.thisUserNotAllowedChatAnyone }}</div>
+ </template>
+ <template v-else-if="room">
+ <div>{{ i18n.ts._chat.inviteUserToChat }}</div>
+ </template>
+ </div>
+ </div>
+
+ <div v-else class="_gaps">
+ <div v-if="canFetchMore">
+ <MkButton :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMore">{{ i18n.ts.loadMore }}</MkButton>
+ </div>
+
+ <TransitionGroup
+ :enterActiveClass="prefer.s.animation ? $style.transition_x_enterActive : ''"
+ :leaveActiveClass="prefer.s.animation ? $style.transition_x_leaveActive : ''"
+ :enterFromClass="prefer.s.animation ? $style.transition_x_enterFrom : ''"
+ :leaveToClass="prefer.s.animation ? $style.transition_x_leaveTo : ''"
+ :moveClass="prefer.s.animation ? $style.transition_x_move : ''"
+ tag="div" class="_gaps"
+ >
+ <XMessage v-for="message in messages.toReversed()" :key="message.id" :message="message"/>
+ </TransitionGroup>
+ </div>
+ </MkSpacer>
+
+ <MkSpacer v-else-if="tab === 'search'" :contentMax="700">
+ <XSearch :userId="userId" :roomId="roomId"/>
+ </MkSpacer>
+
+ <MkSpacer v-else-if="tab === 'members'" :contentMax="700">
+ <XMembers v-if="room != null" :room="room" @inviteUser="inviteUser"/>
+ </MkSpacer>
+
+ <MkSpacer v-else-if="tab === 'info'" :contentMax="700">
+ <XInfo v-if="room != null" :room="room"/>
+ </MkSpacer>
+
+ <template #footer>
+ <div v-if="tab === 'chat'" :class="$style.footer">
+ <div class="_gaps">
+ <Transition name="fade">
+ <div v-show="showIndicator" :class="$style.new">
+ <button class="_buttonPrimary" :class="$style.newButton" @click="onIndicatorClick">
+ <i class="fas ti-fw fa-arrow-circle-down" :class="$style.newIcon"></i>{{ i18n.ts.newMessageExists }}
+ </button>
+ </div>
+ </Transition>
+ <XForm v-if="!initializing" :user="user" :room="room" :class="$style.form"/>
+ </div>
+ </div>
+ </template>
+</PageWithHeader>
+</template>
+
+<script lang="ts" setup>
+import { ref, useTemplateRef, computed, watch, onMounted, nextTick, onBeforeUnmount, onDeactivated, onActivated } from 'vue';
+import * as Misskey from 'misskey-js';
+import { isTailVisible } from '@@/js/scroll.js';
+import XMessage from './XMessage.vue';
+import XForm from './room.form.vue';
+import XSearch from './room.search.vue';
+import XMembers from './room.members.vue';
+import XInfo from './room.info.vue';
+import type { MenuItem } from '@/types/menu.js';
+import * as os from '@/os.js';
+import { useStream } from '@/stream.js';
+import * as sound from '@/utility/sound.js';
+import { i18n } from '@/i18n.js';
+import { ensureSignin } from '@/i.js';
+import { misskeyApi } from '@/utility/misskey-api.js';
+import { definePage } from '@/page.js';
+import { prefer } from '@/preferences.js';
+import MkButton from '@/components/MkButton.vue';
+import { useRouter } from '@/router.js';
+
+const $i = ensureSignin();
+const router = useRouter();
+
+const props = defineProps<{
+ userId?: string;
+ roomId?: string;
+}>();
+
+const initializing = ref(true);
+const moreFetching = ref(false);
+const messages = ref<Misskey.entities.ChatMessage[]>([]);
+const canFetchMore = ref(false);
+const user = ref<Misskey.entities.UserDetailed | null>(null);
+const room = ref<Misskey.entities.ChatRoom | null>(null);
+const connection = ref<Misskey.ChannelConnection<Misskey.Channels['chatUser'] | Misskey.Channels['chatRoom']> | null>(null);
+const showIndicator = ref(false);
+
+function normalizeMessage(message: Misskey.entities.ChatMessageLite | Misskey.entities.ChatMessage) {
+ const reactions = [...message.reactions];
+ for (const record of reactions) {
+ if (room.value == null && record.user == null) { // 1on1の時はuserは省略される
+ record.user = message.fromUserId === $i.id ? user.value : $i;
+ }
+ }
+
+ return {
+ ...message,
+ fromUser: message.fromUser ?? (message.fromUserId === $i.id ? $i : user),
+ reactions,
+ };
+}
+
+async function initialize() {
+ const LIMIT = 20;
+
+ initializing.value = true;
+
+ if (props.userId) {
+ const [u, m] = await Promise.all([
+ misskeyApi('users/show', { userId: props.userId }),
+ misskeyApi('chat/messages/user-timeline', { userId: props.userId, limit: LIMIT }),
+ ]);
+
+ user.value = u;
+ messages.value = m.map(x => normalizeMessage(x));
+
+ if (messages.value.length === LIMIT) {
+ canFetchMore.value = true;
+ }
+
+ connection.value = useStream().useChannel('chatUser', {
+ otherId: user.value.id,
+ });
+ connection.value.on('message', onMessage);
+ connection.value.on('deleted', onDeleted);
+ connection.value.on('react', onReact);
+ } else {
+ const [r, m] = await Promise.all([
+ misskeyApi('chat/rooms/show', { roomId: props.roomId }),
+ misskeyApi('chat/messages/room-timeline', { roomId: props.roomId, limit: LIMIT }),
+ ]);
+
+ room.value = r;
+ messages.value = m.map(x => normalizeMessage(x));
+
+ if (messages.value.length === LIMIT) {
+ canFetchMore.value = true;
+ }
+
+ connection.value = useStream().useChannel('chatRoom', {
+ roomId: room.value.id,
+ });
+ connection.value.on('message', onMessage);
+ connection.value.on('deleted', onDeleted);
+ connection.value.on('react', onReact);
+ }
+
+ window.document.addEventListener('visibilitychange', onVisibilitychange);
+
+ initializing.value = false;
+}
+
+let isActivated = true;
+
+onActivated(() => {
+ isActivated = true;
+});
+
+onDeactivated(() => {
+ isActivated = false;
+});
+
+async function fetchMore() {
+ const LIMIT = 30;
+
+ moreFetching.value = true;
+
+ const newMessages = props.userId ? await misskeyApi('chat/messages/user-timeline', {
+ userId: user.value.id,
+ limit: LIMIT,
+ untilId: messages.value[messages.value.length - 1].id,
+ }) : await misskeyApi('chat/messages/room-timeline', {
+ roomId: room.value.id,
+ limit: LIMIT,
+ untilId: messages.value[messages.value.length - 1].id,
+ });
+
+ messages.value.push(...newMessages.map(x => normalizeMessage(x)));
+
+ canFetchMore.value = newMessages.length === LIMIT;
+ moreFetching.value = false;
+}
+
+function onMessage(message: Misskey.entities.ChatMessage) {
+ sound.playMisskeySfx('chatMessage');
+
+ messages.value.unshift(normalizeMessage(message));
+
+ // TODO: DOM的にバックグラウンドになっていないかどうかも考慮する
+ if (message.fromUserId !== $i.id && !window.document.hidden && isActivated) {
+ connection.value?.send('read', {
+ id: message.id,
+ });
+ }
+
+ if (message.fromUserId !== $i.id) {
+ //notifyNewMessage();
+ }
+}
+
+function onDeleted(id) {
+ const index = messages.value.findIndex(m => m.id === id);
+ if (index !== -1) {
+ messages.value.splice(index, 1);
+ }
+}
+
+function onReact(ctx) {
+ const message = messages.value.find(m => m.id === ctx.messageId);
+ if (message) {
+ if (room.value == null) { // 1on1の時はuserは省略される
+ message.reactions.push({
+ reaction: ctx.reaction,
+ user: message.fromUserId === $i.id ? user : $i,
+ });
+ } else {
+ message.reactions.push({
+ reaction: ctx.reaction,
+ user: ctx.user,
+ });
+ }
+ }
+}
+
+function onIndicatorClick() {
+ showIndicator.value = false;
+}
+
+function notifyNewMessage() {
+ showIndicator.value = true;
+}
+
+function onVisibilitychange() {
+ if (window.document.hidden) return;
+ // TODO
+}
+
+onMounted(() => {
+ initialize();
+});
+
+onBeforeUnmount(() => {
+ connection.value?.dispose();
+ window.document.removeEventListener('visibilitychange', onVisibilitychange);
+});
+
+async function inviteUser() {
+ const invitee = await os.selectUser({ includeSelf: false, localOnly: true });
+ os.apiWithDialog('chat/rooms/invitations/create', {
+ roomId: room.value?.id,
+ userId: invitee.id,
+ });
+}
+
+async function leaveRoom() {
+ const { canceled } = await os.confirm({
+ type: 'warning',
+ text: i18n.ts.areYouSure,
+ });
+ if (canceled) return;
+
+ misskeyApi('chat/rooms/leave', {
+ roomId: room.value?.id,
+ });
+ router.push('/chat');
+}
+
+function showMenu(ev: MouseEvent) {
+ const menuItems: MenuItem[] = [];
+
+ if (room.value) {
+ if (room.value.ownerId === $i.id) {
+ menuItems.push({
+ text: i18n.ts._chat.inviteUser,
+ icon: 'ti ti-user-plus',
+ action: () => {
+ inviteUser();
+ },
+ });
+ } else {
+ menuItems.push({
+ text: i18n.ts._chat.leave,
+ icon: 'ti ti-x',
+ action: () => {
+ leaveRoom();
+ },
+ });
+ }
+ }
+
+ os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
+}
+
+const tab = ref('chat');
+
+const headerTabs = computed(() => room.value ? [{
+ key: 'chat',
+ title: i18n.ts.chat,
+ icon: 'ti ti-messages',
+}, {
+ key: 'members',
+ title: i18n.ts._chat.members,
+ icon: 'ti ti-users',
+}, {
+ key: 'search',
+ title: i18n.ts.search,
+ icon: 'ti ti-search',
+}, {
+ key: 'info',
+ title: i18n.ts.info,
+ icon: 'ti ti-info-circle',
+}] : [{
+ key: 'chat',
+ title: i18n.ts.chat,
+ icon: 'ti ti-messages',
+}, {
+ key: 'search',
+ title: i18n.ts.search,
+ icon: 'ti ti-search',
+}]);
+
+const headerActions = computed(() => [{
+ icon: 'ti ti-dots',
+ handler: showMenu,
+}]);
+
+definePage(computed(() => !initializing.value ? user.value ? {
+ userName: user,
+ avatar: user,
+} : {
+ title: room.value?.name,
+ icon: 'ti ti-users',
+} : null));
+</script>
+
+<style lang="scss" module>
+.transition_x_move,
+.transition_x_enterActive,
+.transition_x_leaveActive {
+ transition: opacity 0.2s cubic-bezier(0,.5,.5,1), transform 0.2s cubic-bezier(0,.5,.5,1) !important;
+}
+.transition_x_enterFrom,
+.transition_x_leaveTo {
+ opacity: 0;
+ transform: translateY(80px);
+}
+.transition_x_leaveActive {
+ position: absolute;
+}
+
+.root {
+}
+
+.more {
+ margin: 0 auto;
+}
+
+.footer {
+ width: 100%;
+ padding-top: 8px;
+}
+
+.new {
+ width: 100%;
+ padding-bottom: 8px;
+ text-align: center;
+}
+
+.newButton {
+ display: inline-block;
+ margin: 0;
+ padding: 0 12px;
+ line-height: 32px;
+ font-size: 12px;
+ border-radius: 16px;
+}
+
+.newIcon {
+ display: inline-block;
+ margin-right: 8px;
+}
+
+.footer {
+
+}
+
+.form {
+ margin: 0 auto;
+ width: 100%;
+ max-width: 700px;
+}
+
+.fade-enter-active, .fade-leave-active {
+ transition: opacity 0.1s;
+}
+
+.fade-enter-from, .fade-leave-to {
+ transition: opacity 0.5s;
+ opacity: 0;
+}
+</style>
diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue
index 530b63b701..93a41e9ddd 100644
--- a/packages/frontend/src/pages/settings/notifications.vue
+++ b/packages/frontend/src/pages/settings/notifications.vue
@@ -38,7 +38,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<FormSection>
<div class="_gaps_m">
<FormLink @click="readAllNotifications">{{ i18n.ts.markAsReadAllNotifications }}</FormLink>
- <FormLink @click="readAllUnreadNotes">{{ i18n.ts.markAsReadAllUnreadNotes }}</FormLink>
</div>
</FormSection>
<FormSection>
@@ -93,10 +92,6 @@ const pushRegistrationInServer = computed(() => allowButton.value?.pushRegistrat
const sendReadMessage = computed(() => pushRegistrationInServer.value?.sendReadMessage || false);
const userLists = await misskeyApi('users/lists/list');
-async function readAllUnreadNotes() {
- await os.apiWithDialog('i/read-all-unread-notes');
-}
-
async function readAllNotifications() {
await os.apiWithDialog('notifications/mark-all-as-read');
}
diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue
index f6eb203095..2f8a697d74 100644
--- a/packages/frontend/src/pages/settings/privacy.vue
+++ b/packages/frontend/src/pages/settings/privacy.vue
@@ -78,6 +78,20 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</SearchMarker>
+ <FormSection>
+ <SearchMarker :keywords="['chat']">
+ <MkSelect v-model="chatScope" @update:modelValue="save()">
+ <template #label><SearchLabel>{{ i18n.ts._chat.chatAllowedUsers }}</SearchLabel></template>
+ <option value="everyone">{{ i18n.ts._chat._chatAllowedUsers.everyone }}</option>
+ <option value="followers">{{ i18n.ts._chat._chatAllowedUsers.followers }}</option>
+ <option value="following">{{ i18n.ts._chat._chatAllowedUsers.following }}</option>
+ <option value="mutual">{{ i18n.ts._chat._chatAllowedUsers.mutual }}</option>
+ <option value="none">{{ i18n.ts._chat._chatAllowedUsers.none }}</option>
+ <template #caption>{{ i18n.ts._chat.chatAllowedUsers_note }}</template>
+ </MkSelect>
+ </SearchMarker>
+ </FormSection>
+
<SearchMarker :keywords="['lockdown']">
<FormSection>
<template #label><SearchLabel>{{ i18n.ts.lockdown }}</SearchLabel><span class="_beta">{{ i18n.ts.beta }}</span></template>
@@ -208,6 +222,7 @@ const hideOnlineStatus = ref($i.hideOnlineStatus);
const publicReactions = ref($i.publicReactions);
const followingVisibility = ref($i.followingVisibility);
const followersVisibility = ref($i.followersVisibility);
+const chatScope = ref($i.chatScope);
const makeNotesFollowersOnlyBefore_type = computed(() => {
if (makeNotesFollowersOnlyBefore.value == null) {
@@ -260,6 +275,7 @@ function save() {
publicReactions: !!publicReactions.value,
followingVisibility: followingVisibility.value,
followersVisibility: followersVisibility.value,
+ chatScope: chatScope.value,
});
}
diff --git a/packages/frontend/src/pages/settings/sounds.vue b/packages/frontend/src/pages/settings/sounds.vue
index 9e5c82a266..4461ee1ab1 100644
--- a/packages/frontend/src/pages/settings/sounds.vue
+++ b/packages/frontend/src/pages/settings/sounds.vue
@@ -85,6 +85,7 @@ const sounds = ref<Record<OperationType, Ref<SoundStore>>>({
noteMy: prefer.r['sound.on.noteMy'],
notification: prefer.r['sound.on.notification'],
reaction: prefer.r['sound.on.reaction'],
+ chatMessage: prefer.r['sound.on.chatMessage'],
});
function getSoundTypeName(f: SoundType): string {
diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts
index c3c37553d7..127ebeef0c 100644
--- a/packages/frontend/src/preferences/def.ts
+++ b/packages/frontend/src/preferences/def.ts
@@ -136,6 +136,7 @@ export const PREF_DEF = {
'clips',
'drive',
'followRequests',
+ 'chat',
'-',
'explore',
'announcements',
@@ -331,6 +332,7 @@ export const PREF_DEF = {
plugins: {
default: [] as Plugin[],
},
+
'sound.masterVolume': {
default: 0.3,
},
@@ -352,6 +354,10 @@ export const PREF_DEF = {
'sound.on.reaction': {
default: { type: 'syuilo/bubble2', volume: 1 } as SoundStore,
},
+ 'sound.on.chatMessage': {
+ default: { type: 'syuilo/waon', volume: 1 } as SoundStore,
+ },
+
'deck.alwaysShowMainColumn': {
default: true,
},
@@ -364,6 +370,7 @@ export const PREF_DEF = {
'deck.columnAlign': {
default: 'left' as 'left' | 'right' | 'center',
},
+
'game.dropAndFusion': {
default: {
bgmVolume: 0.25,
diff --git a/packages/frontend/src/router.definition.ts b/packages/frontend/src/router.definition.ts
index 3b60ee68e3..0585a31fd1 100644
--- a/packages/frontend/src/router.definition.ts
+++ b/packages/frontend/src/router.definition.ts
@@ -41,6 +41,22 @@ export const ROUTE_DEF = [{
path: '/clips/:clipId',
component: page(() => import('@/pages/clip.vue')),
}, {
+ path: '/chat',
+ component: page(() => import('@/pages/chat/home.vue')),
+ loginRequired: true,
+}, {
+ path: '/chat/user/:userId',
+ component: page(() => import('@/pages/chat/room.vue')),
+ loginRequired: true,
+}, {
+ path: '/chat/room/:roomId',
+ component: page(() => import('@/pages/chat/room.vue')),
+ loginRequired: true,
+}, {
+ path: '/chat/messages/:messageId',
+ component: page(() => import('@/pages/chat/message.vue')),
+ loginRequired: true,
+}, {
path: '/instance-info/:host',
component: page(() => import('@/pages/instance-info.vue')),
}, {
diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss
index 8c5617b72e..f122f47c06 100644
--- a/packages/frontend/src/style.scss
+++ b/packages/frontend/src/style.scss
@@ -113,10 +113,6 @@ a {
outline-offset: 2px;
}
- &:hover {
- text-decoration: underline;
- }
-
&[target="_blank"] {
-webkit-touch-callout: default;
}
@@ -335,13 +331,13 @@ rt {
._gaps_m {
display: flex;
flex-direction: column;
- gap: 1.5em;
+ gap: 21px;
}
._gaps_s {
display: flex;
flex-direction: column;
- gap: 0.75em;
+ gap: 10px;
}
._gaps {
diff --git a/packages/frontend/src/types/menu.ts b/packages/frontend/src/types/menu.ts
index 5d1fc1fe72..820759ce61 100644
--- a/packages/frontend/src/types/menu.ts
+++ b/packages/frontend/src/types/menu.ts
@@ -15,16 +15,16 @@ export type MenuAction = (ev: MouseEvent) => void;
export type MenuDivider = { type: 'divider' };
export type MenuNull = undefined;
-export type MenuLabel = { type: 'label', text: string };
-export type MenuLink = { type: 'link', to: string, text: string, icon?: string, indicate?: boolean, avatar?: Misskey.entities.User };
-export type MenuA = { type: 'a', href: string, target?: string, download?: string, text: string, icon?: string, indicate?: boolean };
+export type MenuLabel = { type: 'label', text: string, caption?: string };
+export type MenuLink = { type: 'link', to: string, text: string, caption?: string, icon?: string, indicate?: boolean, avatar?: Misskey.entities.User };
+export type MenuA = { type: 'a', href: string, target?: string, download?: string, text: string, caption?: string, icon?: string, indicate?: boolean };
export type MenuUser = { type: 'user', user: Misskey.entities.User, active?: boolean, indicate?: boolean, action: MenuAction };
-export type MenuSwitch = { type: 'switch', ref: Ref<boolean>, text: string, icon?: string, disabled?: boolean | Ref<boolean> };
-export type MenuButton = { type?: 'button', text: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean | ComputedRef<boolean>, avatar?: Misskey.entities.User; action: MenuAction };
-export type MenuRadio = { type: 'radio', text: string, icon?: string, ref: Ref<MenuRadioOptionsDef[keyof MenuRadioOptionsDef]>, options: MenuRadioOptionsDef, disabled?: boolean | Ref<boolean> };
-export type MenuRadioOption = { type: 'radioOption', text: string, action: MenuAction; active?: boolean | ComputedRef<boolean> };
+export type MenuSwitch = { type: 'switch', ref: Ref<boolean>, text: string, caption?: string, icon?: string, disabled?: boolean | Ref<boolean> };
+export type MenuButton = { type?: 'button', text: string, caption?: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean | ComputedRef<boolean>, avatar?: Misskey.entities.User; action: MenuAction };
+export type MenuRadio = { type: 'radio', text: string, caption?: string, icon?: string, ref: Ref<MenuRadioOptionsDef[keyof MenuRadioOptionsDef]>, options: MenuRadioOptionsDef, disabled?: boolean | Ref<boolean> };
+export type MenuRadioOption = { type: 'radioOption', text: string, caption?: string, action: MenuAction; active?: boolean | ComputedRef<boolean> };
export type MenuComponent<T extends Component = any> = { type: 'component', component: T, props?: ComponentProps<T> };
-export type MenuParent = { type: 'parent', text: string, icon?: string, children: MenuItem[] | (() => Promise<MenuItem[]> | MenuItem[]) };
+export type MenuParent = { type: 'parent', text: string, caption?: string, icon?: string, children: MenuItem[] | (() => Promise<MenuItem[]> | MenuItem[]) };
export type MenuPending = { type: 'pending' };
diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue
index 98d6f329ab..88ce3a96e2 100644
--- a/packages/frontend/src/ui/_common_/navbar.vue
+++ b/packages/frontend/src/ui/_common_/navbar.vue
@@ -65,7 +65,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</svg>
-->
- <div :class="$style.subButtons">
+ <div v-if="!forceIconOnly" :class="$style.subButtons">
<div :class="[$style.subButton, $style.menuEditButton]">
<svg viewBox="0 0 16 64" :class="$style.subButtonShape">
<g transform="matrix(0.333333,0,0,0.222222,0.000895785,21.3333)">
@@ -74,9 +74,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</svg>
<button class="_button" :class="$style.subButtonClickable" @click="menuEdit"><i :class="$style.subButtonIcon" class="ti ti-settings-2"></i></button>
</div>
- <div v-if="!forceIconOnly" :class="$style.subButtonGapFill"></div>
- <div v-if="!forceIconOnly" :class="$style.subButtonGapFillDivider"></div>
- <div v-if="!forceIconOnly" :class="[$style.subButton, $style.toggleButton]">
+ <div :class="$style.subButtonGapFill"></div>
+ <div :class="$style.subButtonGapFillDivider"></div>
+ <div :class="[$style.subButton, $style.toggleButton]">
<svg viewBox="0 0 16 64" :class="$style.subButtonShape">
<g transform="matrix(0.333333,0,0,0.222222,0.000895785,21.3333)">
<path d="M47.488,7.995C47.79,10.11 47.943,12.266 47.943,14.429C47.997,26.989 47.997,84 47.997,84C47.997,84 44.018,118.246 23.997,133.5C-0.374,152.07 -0.003,192 -0.003,192L-0.003,-96C-0.003,-96 0.151,-56.216 23.997,-37.5C40.861,-24.265 46.043,-1.243 47.488,7.995Z" style="fill:var(--MI_THEME-navBg);"/>
diff --git a/packages/frontend/src/utility/autogen/settings-search-index.ts b/packages/frontend/src/utility/autogen/settings-search-index.ts
index fd92876880..64fe328478 100644
--- a/packages/frontend/src/utility/autogen/settings-search-index.ts
+++ b/packages/frontend/src/utility/autogen/settings-search-index.ts
@@ -240,20 +240,25 @@ export const searchIndexes: SearchIndexItem[] = [
keywords: ['explore', i18n.ts.makeExplorableDescription],
},
{
- id: '7vr04wKol',
+ id: 'xEYlOghao',
+ label: i18n.ts._chat.chatAllowedUsers,
+ keywords: ['chat'],
+ },
+ {
+ id: 'BnOtlyaAh',
children: [
{
- id: 'Av7fAaHv8',
+ id: 'BzMIVBpL0',
label: i18n.ts._accountSettings.requireSigninToViewContents,
keywords: ['login', 'signin'],
},
{
- id: '5RbESWefG',
+ id: 'jJUqPqBAv',
label: i18n.ts._accountSettings.makeNotesFollowersOnlyBefore,
keywords: ['follower', i18n.ts._accountSettings.makeNotesFollowersOnlyBeforeDescription],
},
{
- id: 'hdzwDs3qd',
+ id: 'ra10txIFV',
label: i18n.ts._accountSettings.makeNotesHiddenBefore,
keywords: ['hidden', i18n.ts._accountSettings.makeNotesHiddenBeforeDescription],
},
diff --git a/packages/frontend/src/utility/get-user-menu.ts b/packages/frontend/src/utility/get-user-menu.ts
index de20f2678e..37c88c9665 100644
--- a/packages/frontend/src/utility/get-user-menu.ts
+++ b/packages/frontend/src/utility/get-user-menu.ts
@@ -151,24 +151,6 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
const menuItems: MenuItem[] = [];
- menuItems.push({
- icon: 'ti ti-at',
- text: i18n.ts.copyUsername,
- action: () => {
- copyToClipboard(`@${user.username}@${user.host ?? host}`);
- },
- });
-
- if (notesSearchAvailable && (user.host == null || canSearchNonLocalNotes)) {
- menuItems.push({
- icon: 'ti ti-search',
- text: i18n.ts.searchThisUsersNotes,
- action: () => {
- router.push(`/search?username=${encodeURIComponent(user.username)}${user.host != null ? '&host=' + encodeURIComponent(user.host) : ''}`);
- },
- });
- }
-
if (iAmModerator) {
menuItems.push({
icon: 'ti ti-user-exclamation',
@@ -176,10 +158,27 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
action: () => {
router.push(`/admin/user/${user.id}`);
},
- });
+ }, { type: 'divider' });
}
menuItems.push({
+ icon: 'ti ti-at',
+ text: i18n.ts.copyUsername,
+ action: () => {
+ copyToClipboard(`@${user.username}@${user.host ?? host}`);
+ },
+ });
+
+ menuItems.push({
+ icon: 'ti ti-share',
+ text: i18n.ts.copyProfileUrl,
+ action: () => {
+ const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`;
+ copyToClipboard(`${url}/${canonical}`);
+ },
+ });
+
+ menuItems.push({
icon: 'ti ti-rss',
text: i18n.ts.copyRSS,
action: () => {
@@ -210,24 +209,18 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
});
}
- menuItems.push({
- icon: 'ti ti-share',
- text: i18n.ts.copyProfileUrl,
- action: () => {
- const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`;
- copyToClipboard(`${url}/${canonical}`);
- },
- });
-
- if ($i) {
+ if (notesSearchAvailable && (user.host == null || canSearchNonLocalNotes)) {
menuItems.push({
- icon: 'ti ti-mail',
- text: i18n.ts.sendMessage,
+ icon: 'ti ti-search',
+ text: i18n.ts.searchThisUsersNotes,
action: () => {
- const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${user.host}`;
- os.post({ specified: user, initialText: `${canonical} ` });
+ router.push(`/search?username=${encodeURIComponent(user.username)}${user.host != null ? '&host=' + encodeURIComponent(user.host) : ''}`);
},
- }, { type: 'divider' }, {
+ });
+ }
+
+ if ($i) {
+ menuItems.push({ type: 'divider' }, {
icon: 'ti ti-pencil',
text: i18n.ts.editMemo,
action: editMemo,
@@ -363,6 +356,18 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
//}
menuItems.push({ type: 'divider' }, {
+ icon: 'ti ti-mail',
+ text: i18n.ts.sendMessage,
+ action: () => {
+ const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${user.host}`;
+ os.post({ specified: user, initialText: `${canonical} ` });
+ },
+ }, {
+ type: 'link',
+ icon: 'ti ti-messages',
+ text: i18n.ts._chat.chatWithThisUser,
+ to: `/chat/user/${user.id}`,
+ }, { type: 'divider' }, {
icon: user.isMuted ? 'ti ti-eye' : 'ti ti-eye-off',
text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute,
action: toggleMute,
diff --git a/packages/frontend/src/utility/sound.ts b/packages/frontend/src/utility/sound.ts
index 796af0e5ca..f217bdfcd5 100644
--- a/packages/frontend/src/utility/sound.ts
+++ b/packages/frontend/src/utility/sound.ts
@@ -77,6 +77,7 @@ export const operationTypes = [
'note',
'notification',
'reaction',
+ 'chatMessage',
] as const;
/** サウンドの種類 */
diff --git a/packages/frontend/src/utility/upload.ts b/packages/frontend/src/utility/upload.ts
index eb3cbd3dfa..e13d793ffb 100644
--- a/packages/frontend/src/utility/upload.ts
+++ b/packages/frontend/src/utility/upload.ts
@@ -32,7 +32,7 @@ const mimeTypeMap = {
export function uploadFile(
file: File,
- folder?: string | Misskey.entities.DriveFolder,
+ folder?: string | Misskey.entities.DriveFolder | null,
name?: string,
keepOriginal: boolean = prefer.s.keepOriginalUploading,
): Promise<Misskey.entities.DriveFile> {
diff --git a/packages/misskey-js/README.md b/packages/misskey-js/README.md
index 4753e2434b..5ab2787c47 100644
--- a/packages/misskey-js/README.md
+++ b/packages/misskey-js/README.md
@@ -83,8 +83,8 @@ const mainChannel = stream.useChannel('main');
``` ts
const stream = new Misskey.Stream('https://misskey.test', { token: 'TOKEN' });
-const messagingChannel = stream.useChannel('messaging', {
- otherparty: 'xxxxxxxxxx',
+const chatChannel = stream.useChannel('chat', {
+ other: 'xxxxxxxxxx',
});
```
@@ -115,11 +115,11 @@ mainChannel.on('notification', notification => {
``` ts
const stream = new Misskey.Stream('https://misskey.test', { token: 'TOKEN' });
-const messagingChannel = stream.useChannel('messaging', {
- otherparty: 'xxxxxxxxxx',
+const chatChannel = stream.useChannel('chat', {
+ other: 'xxxxxxxxxx',
});
-messagingChannel.send('read', {
+chatChannel.send('read', {
id: 'xxxxxxxxxx'
});
```
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index 6060812b53..e79cd794a6 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -619,13 +619,9 @@ export type Channels = {
}) => void;
readAllNotifications: () => void;
unreadNotification: (payload: Notification_2) => void;
- unreadMention: (payload: Note['id']) => void;
- readAllUnreadMentions: () => void;
notificationFlushed: () => void;
- unreadSpecifiedNote: (payload: Note['id']) => void;
- readAllUnreadSpecifiedNotes: () => void;
- readAllAntennas: () => void;
unreadAntenna: (payload: Antenna) => void;
+ newChatMessage: (payload: ChatMessage) => void;
readAllAnnouncements: () => void;
myTokenRegenerated: () => void;
signin: (payload: Signin) => void;
@@ -952,6 +948,153 @@ type ChartsUsersRequest = operations['charts___users']['requestBody']['content']
type ChartsUsersResponse = operations['charts___users']['responses']['200']['content']['application/json'];
// @public (undocumented)
+type ChatHistoryRequest = operations['chat___history']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatHistoryResponse = operations['chat___history']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessage = components['schemas']['ChatMessage'];
+
+// @public (undocumented)
+type ChatMessageLite = components['schemas']['ChatMessageLite'];
+
+// @public (undocumented)
+type ChatMessagesCreateToRoomRequest = operations['chat___messages___create-to-room']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesCreateToRoomResponse = operations['chat___messages___create-to-room']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesCreateToUserRequest = operations['chat___messages___create-to-user']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesCreateToUserResponse = operations['chat___messages___create-to-user']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesDeleteRequest = operations['chat___messages___delete']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesDeleteResponse = operations['chat___messages___delete']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesReactRequest = operations['chat___messages___react']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesReactResponse = operations['chat___messages___react']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesRoomTimelineRequest = operations['chat___messages___room-timeline']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesRoomTimelineResponse = operations['chat___messages___room-timeline']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesSearchRequest = operations['chat___messages___search']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesSearchResponse = operations['chat___messages___search']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesShowRequest = operations['chat___messages___show']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesShowResponse = operations['chat___messages___show']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesUserTimelineRequest = operations['chat___messages___user-timeline']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatMessagesUserTimelineResponse = operations['chat___messages___user-timeline']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoom = components['schemas']['ChatRoom'];
+
+// @public (undocumented)
+type ChatRoomInvitation = components['schemas']['ChatRoomInvitation'];
+
+// @public (undocumented)
+type ChatRoomMembership = components['schemas']['ChatRoomMembership'];
+
+// @public (undocumented)
+type ChatRoomsCreateRequest = operations['chat___rooms___create']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsCreateResponse = operations['chat___rooms___create']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsDeleteRequest = operations['chat___rooms___delete']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsDeleteResponse = operations['chat___rooms___delete']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsInvitationsCreateRequest = operations['chat___rooms___invitations___create']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsInvitationsCreateResponse = operations['chat___rooms___invitations___create']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsInvitationsIgnoreRequest = operations['chat___rooms___invitations___ignore']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsInvitationsIgnoreResponse = operations['chat___rooms___invitations___ignore']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsInvitationsInboxRequest = operations['chat___rooms___invitations___inbox']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsInvitationsInboxResponse = operations['chat___rooms___invitations___inbox']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsJoiningRequest = operations['chat___rooms___joining']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsJoiningResponse = operations['chat___rooms___joining']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsJoinRequest = operations['chat___rooms___join']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsJoinResponse = operations['chat___rooms___join']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsLeaveRequest = operations['chat___rooms___leave']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsLeaveResponse = operations['chat___rooms___leave']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsMembersRequest = operations['chat___rooms___members']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsMembersResponse = operations['chat___rooms___members']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsMuteRequest = operations['chat___rooms___mute']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsMuteResponse = operations['chat___rooms___mute']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsOwnedRequest = operations['chat___rooms___owned']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsOwnedResponse = operations['chat___rooms___owned']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsShowRequest = operations['chat___rooms___show']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsShowResponse = operations['chat___rooms___show']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsUpdateRequest = operations['chat___rooms___update']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type ChatRoomsUpdateResponse = operations['chat___rooms___update']['responses']['200']['content']['application/json'];
+
+// @public (undocumented)
type Clip = components['schemas']['Clip'];
// @public (undocumented)
@@ -1448,6 +1591,50 @@ declare namespace entities {
ChartsUserReactionsResponse,
ChartsUsersRequest,
ChartsUsersResponse,
+ ChatHistoryRequest,
+ ChatHistoryResponse,
+ ChatMessagesCreateToRoomRequest,
+ ChatMessagesCreateToRoomResponse,
+ ChatMessagesCreateToUserRequest,
+ ChatMessagesCreateToUserResponse,
+ ChatMessagesDeleteRequest,
+ ChatMessagesDeleteResponse,
+ ChatMessagesReactRequest,
+ ChatMessagesReactResponse,
+ ChatMessagesRoomTimelineRequest,
+ ChatMessagesRoomTimelineResponse,
+ ChatMessagesSearchRequest,
+ ChatMessagesSearchResponse,
+ ChatMessagesShowRequest,
+ ChatMessagesShowResponse,
+ ChatMessagesUserTimelineRequest,
+ ChatMessagesUserTimelineResponse,
+ ChatRoomsCreateRequest,
+ ChatRoomsCreateResponse,
+ ChatRoomsDeleteRequest,
+ ChatRoomsDeleteResponse,
+ ChatRoomsInvitationsCreateRequest,
+ ChatRoomsInvitationsCreateResponse,
+ ChatRoomsInvitationsIgnoreRequest,
+ ChatRoomsInvitationsIgnoreResponse,
+ ChatRoomsInvitationsInboxRequest,
+ ChatRoomsInvitationsInboxResponse,
+ ChatRoomsJoinRequest,
+ ChatRoomsJoinResponse,
+ ChatRoomsJoiningRequest,
+ ChatRoomsJoiningResponse,
+ ChatRoomsLeaveRequest,
+ ChatRoomsLeaveResponse,
+ ChatRoomsMembersRequest,
+ ChatRoomsMembersResponse,
+ ChatRoomsMuteRequest,
+ ChatRoomsMuteResponse,
+ ChatRoomsOwnedRequest,
+ ChatRoomsOwnedResponse,
+ ChatRoomsShowRequest,
+ ChatRoomsShowResponse,
+ ChatRoomsUpdateRequest,
+ ChatRoomsUpdateResponse,
ClipsAddNoteRequest,
ClipsCreateRequest,
ClipsCreateResponse,
@@ -1880,7 +2067,12 @@ declare namespace entities {
MetaDetailedOnly,
MetaDetailed,
SystemWebhook,
- AbuseReportNotificationRecipient
+ AbuseReportNotificationRecipient,
+ ChatMessage,
+ ChatMessageLite,
+ ChatRoom,
+ ChatRoomInvitation,
+ ChatRoomMembership
}
}
export { entities }
@@ -2903,7 +3095,7 @@ type PartialRolePolicyOverride = Partial<{
}>;
// @public (undocumented)
-export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "write:admin:delete-account", "write:admin:delete-all-files-of-a-user", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-user", "write:admin:suspend-user", "write:admin:unset-user-avatar", "write:admin:unset-user-banner", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-note", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse"];
+export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "write:admin:delete-account", "write:admin:delete-all-files-of-a-user", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-user", "write:admin:suspend-user", "write:admin:unset-user-avatar", "write:admin:unset-user-banner", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-note", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse", "write:chat", "read:chat"];
// @public (undocumented)
type PingResponse = operations['ping']['responses']['200']['content']['application/json'];
@@ -3444,8 +3636,8 @@ type V2AdminEmojiListResponse = operations['v2___admin___emoji___list']['respons
//
// src/entities.ts:50:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts
// src/streaming.ts:57:3 - (ae-forgotten-export) The symbol "ReconnectingWebSocket" needs to be exported by the entry point index.d.ts
-// src/streaming.types.ts:220:4 - (ae-forgotten-export) The symbol "ReversiUpdateKey" needs to be exported by the entry point index.d.ts
-// src/streaming.types.ts:230:4 - (ae-forgotten-export) The symbol "ReversiUpdateSettings" needs to be exported by the entry point index.d.ts
+// src/streaming.types.ts:217:4 - (ae-forgotten-export) The symbol "ReversiUpdateKey" needs to be exported by the entry point index.d.ts
+// src/streaming.types.ts:227:4 - (ae-forgotten-export) The symbol "ReversiUpdateSettings" needs to be exported by the entry point index.d.ts
// (No @packageDocumentation comment for this package)
diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
index a1543952fc..e12ba0d6b2 100644
--- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts
+++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
@@ -1537,6 +1537,248 @@ declare module '../api.js' {
/**
* No description provided.
*
+ * **Credential required**: *Yes* / **Permission**: *read:chat*
+ */
+ request<E extends 'chat/history', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ request<E extends 'chat/messages/create-to-room', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ request<E extends 'chat/messages/create-to-user', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ request<E extends 'chat/messages/delete', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ request<E extends 'chat/messages/react', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:chat*
+ */
+ request<E extends 'chat/messages/room-timeline', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:chat*
+ */
+ request<E extends 'chat/messages/search', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:chat*
+ */
+ request<E extends 'chat/messages/show', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:chat*
+ */
+ request<E extends 'chat/messages/user-timeline', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ request<E extends 'chat/rooms/create', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ request<E extends 'chat/rooms/delete', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ request<E extends 'chat/rooms/invitations/create', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ request<E extends 'chat/rooms/invitations/ignore', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:chat*
+ */
+ request<E extends 'chat/rooms/invitations/inbox', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ request<E extends 'chat/rooms/join', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:chat*
+ */
+ request<E extends 'chat/rooms/joining', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ request<E extends 'chat/rooms/leave', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ request<E extends 'chat/rooms/members', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ request<E extends 'chat/rooms/mute', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:chat*
+ */
+ request<E extends 'chat/rooms/owned', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:chat*
+ */
+ request<E extends 'chat/rooms/show', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ request<E extends 'chat/rooms/update', P extends Endpoints[E]['req']>(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise<SwitchCaseResponseType<E, P>>;
+
+ /**
+ * No description provided.
+ *
* **Credential required**: *Yes* / **Permission**: *write:account*
*/
request<E extends 'clips/add-note', P extends Endpoints[E]['req']>(
@@ -2832,17 +3074,6 @@ declare module '../api.js' {
*
* **Credential required**: *Yes* / **Permission**: *write:account*
*/
- request<E extends 'i/read-all-unread-notes', P extends Endpoints[E]['req']>(
- endpoint: E,
- params: P,
- credential?: string | null,
- ): Promise<SwitchCaseResponseType<E, P>>;
-
- /**
- * No description provided.
- *
- * **Credential required**: *Yes* / **Permission**: *write:account*
- */
request<E extends 'i/read-announcement', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts
index 9bb8fb2225..558f4ab514 100644
--- a/packages/misskey-js/src/autogen/endpoint.ts
+++ b/packages/misskey-js/src/autogen/endpoint.ts
@@ -207,6 +207,50 @@ import type {
ChartsUserReactionsResponse,
ChartsUsersRequest,
ChartsUsersResponse,
+ ChatHistoryRequest,
+ ChatHistoryResponse,
+ ChatMessagesCreateToRoomRequest,
+ ChatMessagesCreateToRoomResponse,
+ ChatMessagesCreateToUserRequest,
+ ChatMessagesCreateToUserResponse,
+ ChatMessagesDeleteRequest,
+ ChatMessagesDeleteResponse,
+ ChatMessagesReactRequest,
+ ChatMessagesReactResponse,
+ ChatMessagesRoomTimelineRequest,
+ ChatMessagesRoomTimelineResponse,
+ ChatMessagesSearchRequest,
+ ChatMessagesSearchResponse,
+ ChatMessagesShowRequest,
+ ChatMessagesShowResponse,
+ ChatMessagesUserTimelineRequest,
+ ChatMessagesUserTimelineResponse,
+ ChatRoomsCreateRequest,
+ ChatRoomsCreateResponse,
+ ChatRoomsDeleteRequest,
+ ChatRoomsDeleteResponse,
+ ChatRoomsInvitationsCreateRequest,
+ ChatRoomsInvitationsCreateResponse,
+ ChatRoomsInvitationsIgnoreRequest,
+ ChatRoomsInvitationsIgnoreResponse,
+ ChatRoomsInvitationsInboxRequest,
+ ChatRoomsInvitationsInboxResponse,
+ ChatRoomsJoinRequest,
+ ChatRoomsJoinResponse,
+ ChatRoomsJoiningRequest,
+ ChatRoomsJoiningResponse,
+ ChatRoomsLeaveRequest,
+ ChatRoomsLeaveResponse,
+ ChatRoomsMembersRequest,
+ ChatRoomsMembersResponse,
+ ChatRoomsMuteRequest,
+ ChatRoomsMuteResponse,
+ ChatRoomsOwnedRequest,
+ ChatRoomsOwnedResponse,
+ ChatRoomsShowRequest,
+ ChatRoomsShowResponse,
+ ChatRoomsUpdateRequest,
+ ChatRoomsUpdateResponse,
ClipsAddNoteRequest,
ClipsCreateRequest,
ClipsCreateResponse,
@@ -726,6 +770,28 @@ export type Endpoints = {
'charts/user/pv': { req: ChartsUserPvRequest; res: ChartsUserPvResponse };
'charts/user/reactions': { req: ChartsUserReactionsRequest; res: ChartsUserReactionsResponse };
'charts/users': { req: ChartsUsersRequest; res: ChartsUsersResponse };
+ 'chat/history': { req: ChatHistoryRequest; res: ChatHistoryResponse };
+ 'chat/messages/create-to-room': { req: ChatMessagesCreateToRoomRequest; res: ChatMessagesCreateToRoomResponse };
+ 'chat/messages/create-to-user': { req: ChatMessagesCreateToUserRequest; res: ChatMessagesCreateToUserResponse };
+ 'chat/messages/delete': { req: ChatMessagesDeleteRequest; res: ChatMessagesDeleteResponse };
+ 'chat/messages/react': { req: ChatMessagesReactRequest; res: ChatMessagesReactResponse };
+ 'chat/messages/room-timeline': { req: ChatMessagesRoomTimelineRequest; res: ChatMessagesRoomTimelineResponse };
+ 'chat/messages/search': { req: ChatMessagesSearchRequest; res: ChatMessagesSearchResponse };
+ 'chat/messages/show': { req: ChatMessagesShowRequest; res: ChatMessagesShowResponse };
+ 'chat/messages/user-timeline': { req: ChatMessagesUserTimelineRequest; res: ChatMessagesUserTimelineResponse };
+ 'chat/rooms/create': { req: ChatRoomsCreateRequest; res: ChatRoomsCreateResponse };
+ 'chat/rooms/delete': { req: ChatRoomsDeleteRequest; res: ChatRoomsDeleteResponse };
+ 'chat/rooms/invitations/create': { req: ChatRoomsInvitationsCreateRequest; res: ChatRoomsInvitationsCreateResponse };
+ 'chat/rooms/invitations/ignore': { req: ChatRoomsInvitationsIgnoreRequest; res: ChatRoomsInvitationsIgnoreResponse };
+ 'chat/rooms/invitations/inbox': { req: ChatRoomsInvitationsInboxRequest; res: ChatRoomsInvitationsInboxResponse };
+ 'chat/rooms/join': { req: ChatRoomsJoinRequest; res: ChatRoomsJoinResponse };
+ 'chat/rooms/joining': { req: ChatRoomsJoiningRequest; res: ChatRoomsJoiningResponse };
+ 'chat/rooms/leave': { req: ChatRoomsLeaveRequest; res: ChatRoomsLeaveResponse };
+ 'chat/rooms/members': { req: ChatRoomsMembersRequest; res: ChatRoomsMembersResponse };
+ 'chat/rooms/mute': { req: ChatRoomsMuteRequest; res: ChatRoomsMuteResponse };
+ 'chat/rooms/owned': { req: ChatRoomsOwnedRequest; res: ChatRoomsOwnedResponse };
+ 'chat/rooms/show': { req: ChatRoomsShowRequest; res: ChatRoomsShowResponse };
+ 'chat/rooms/update': { req: ChatRoomsUpdateRequest; res: ChatRoomsUpdateResponse };
'clips/add-note': { req: ClipsAddNoteRequest; res: EmptyResponse };
'clips/create': { req: ClipsCreateRequest; res: ClipsCreateResponse };
'clips/delete': { req: ClipsDeleteRequest; res: EmptyResponse };
@@ -841,7 +907,6 @@ export type Endpoints = {
'i/page-likes': { req: IPageLikesRequest; res: IPageLikesResponse };
'i/pages': { req: IPagesRequest; res: IPagesResponse };
'i/pin': { req: IPinRequest; res: IPinResponse };
- 'i/read-all-unread-notes': { req: EmptyRequest; res: EmptyResponse };
'i/read-announcement': { req: IReadAnnouncementRequest; res: EmptyResponse };
'i/regenerate-token': { req: IRegenerateTokenRequest; res: EmptyResponse };
'i/registry/get': { req: IRegistryGetRequest; res: IRegistryGetResponse };
diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts
index f3d26efa69..bb21272e01 100644
--- a/packages/misskey-js/src/autogen/entities.ts
+++ b/packages/misskey-js/src/autogen/entities.ts
@@ -210,6 +210,50 @@ export type ChartsUserReactionsRequest = operations['charts___user___reactions']
export type ChartsUserReactionsResponse = operations['charts___user___reactions']['responses']['200']['content']['application/json'];
export type ChartsUsersRequest = operations['charts___users']['requestBody']['content']['application/json'];
export type ChartsUsersResponse = operations['charts___users']['responses']['200']['content']['application/json'];
+export type ChatHistoryRequest = operations['chat___history']['requestBody']['content']['application/json'];
+export type ChatHistoryResponse = operations['chat___history']['responses']['200']['content']['application/json'];
+export type ChatMessagesCreateToRoomRequest = operations['chat___messages___create-to-room']['requestBody']['content']['application/json'];
+export type ChatMessagesCreateToRoomResponse = operations['chat___messages___create-to-room']['responses']['200']['content']['application/json'];
+export type ChatMessagesCreateToUserRequest = operations['chat___messages___create-to-user']['requestBody']['content']['application/json'];
+export type ChatMessagesCreateToUserResponse = operations['chat___messages___create-to-user']['responses']['200']['content']['application/json'];
+export type ChatMessagesDeleteRequest = operations['chat___messages___delete']['requestBody']['content']['application/json'];
+export type ChatMessagesDeleteResponse = operations['chat___messages___delete']['responses']['200']['content']['application/json'];
+export type ChatMessagesReactRequest = operations['chat___messages___react']['requestBody']['content']['application/json'];
+export type ChatMessagesReactResponse = operations['chat___messages___react']['responses']['200']['content']['application/json'];
+export type ChatMessagesRoomTimelineRequest = operations['chat___messages___room-timeline']['requestBody']['content']['application/json'];
+export type ChatMessagesRoomTimelineResponse = operations['chat___messages___room-timeline']['responses']['200']['content']['application/json'];
+export type ChatMessagesSearchRequest = operations['chat___messages___search']['requestBody']['content']['application/json'];
+export type ChatMessagesSearchResponse = operations['chat___messages___search']['responses']['200']['content']['application/json'];
+export type ChatMessagesShowRequest = operations['chat___messages___show']['requestBody']['content']['application/json'];
+export type ChatMessagesShowResponse = operations['chat___messages___show']['responses']['200']['content']['application/json'];
+export type ChatMessagesUserTimelineRequest = operations['chat___messages___user-timeline']['requestBody']['content']['application/json'];
+export type ChatMessagesUserTimelineResponse = operations['chat___messages___user-timeline']['responses']['200']['content']['application/json'];
+export type ChatRoomsCreateRequest = operations['chat___rooms___create']['requestBody']['content']['application/json'];
+export type ChatRoomsCreateResponse = operations['chat___rooms___create']['responses']['200']['content']['application/json'];
+export type ChatRoomsDeleteRequest = operations['chat___rooms___delete']['requestBody']['content']['application/json'];
+export type ChatRoomsDeleteResponse = operations['chat___rooms___delete']['responses']['200']['content']['application/json'];
+export type ChatRoomsInvitationsCreateRequest = operations['chat___rooms___invitations___create']['requestBody']['content']['application/json'];
+export type ChatRoomsInvitationsCreateResponse = operations['chat___rooms___invitations___create']['responses']['200']['content']['application/json'];
+export type ChatRoomsInvitationsIgnoreRequest = operations['chat___rooms___invitations___ignore']['requestBody']['content']['application/json'];
+export type ChatRoomsInvitationsIgnoreResponse = operations['chat___rooms___invitations___ignore']['responses']['200']['content']['application/json'];
+export type ChatRoomsInvitationsInboxRequest = operations['chat___rooms___invitations___inbox']['requestBody']['content']['application/json'];
+export type ChatRoomsInvitationsInboxResponse = operations['chat___rooms___invitations___inbox']['responses']['200']['content']['application/json'];
+export type ChatRoomsJoinRequest = operations['chat___rooms___join']['requestBody']['content']['application/json'];
+export type ChatRoomsJoinResponse = operations['chat___rooms___join']['responses']['200']['content']['application/json'];
+export type ChatRoomsJoiningRequest = operations['chat___rooms___joining']['requestBody']['content']['application/json'];
+export type ChatRoomsJoiningResponse = operations['chat___rooms___joining']['responses']['200']['content']['application/json'];
+export type ChatRoomsLeaveRequest = operations['chat___rooms___leave']['requestBody']['content']['application/json'];
+export type ChatRoomsLeaveResponse = operations['chat___rooms___leave']['responses']['200']['content']['application/json'];
+export type ChatRoomsMembersRequest = operations['chat___rooms___members']['requestBody']['content']['application/json'];
+export type ChatRoomsMembersResponse = operations['chat___rooms___members']['responses']['200']['content']['application/json'];
+export type ChatRoomsMuteRequest = operations['chat___rooms___mute']['requestBody']['content']['application/json'];
+export type ChatRoomsMuteResponse = operations['chat___rooms___mute']['responses']['200']['content']['application/json'];
+export type ChatRoomsOwnedRequest = operations['chat___rooms___owned']['requestBody']['content']['application/json'];
+export type ChatRoomsOwnedResponse = operations['chat___rooms___owned']['responses']['200']['content']['application/json'];
+export type ChatRoomsShowRequest = operations['chat___rooms___show']['requestBody']['content']['application/json'];
+export type ChatRoomsShowResponse = operations['chat___rooms___show']['responses']['200']['content']['application/json'];
+export type ChatRoomsUpdateRequest = operations['chat___rooms___update']['requestBody']['content']['application/json'];
+export type ChatRoomsUpdateResponse = operations['chat___rooms___update']['responses']['200']['content']['application/json'];
export type ClipsAddNoteRequest = operations['clips___add-note']['requestBody']['content']['application/json'];
export type ClipsCreateRequest = operations['clips___create']['requestBody']['content']['application/json'];
export type ClipsCreateResponse = operations['clips___create']['responses']['200']['content']['application/json'];
diff --git a/packages/misskey-js/src/autogen/models.ts b/packages/misskey-js/src/autogen/models.ts
index 1a30da4437..0ff9749602 100644
--- a/packages/misskey-js/src/autogen/models.ts
+++ b/packages/misskey-js/src/autogen/models.ts
@@ -54,3 +54,8 @@ export type MetaDetailedOnly = components['schemas']['MetaDetailedOnly'];
export type MetaDetailed = components['schemas']['MetaDetailed'];
export type SystemWebhook = components['schemas']['SystemWebhook'];
export type AbuseReportNotificationRecipient = components['schemas']['AbuseReportNotificationRecipient'];
+export type ChatMessage = components['schemas']['ChatMessage'];
+export type ChatMessageLite = components['schemas']['ChatMessageLite'];
+export type ChatRoom = components['schemas']['ChatRoom'];
+export type ChatRoomInvitation = components['schemas']['ChatRoomInvitation'];
+export type ChatRoomMembership = components['schemas']['ChatRoomMembership'];
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 743aaf1608..31bc34e473 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -1358,6 +1358,204 @@ export type paths = {
*/
post: operations['charts___users'];
};
+ '/chat/history': {
+ /**
+ * chat/history
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:chat*
+ */
+ post: operations['chat___history'];
+ };
+ '/chat/messages/create-to-room': {
+ /**
+ * chat/messages/create-to-room
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ post: operations['chat___messages___create-to-room'];
+ };
+ '/chat/messages/create-to-user': {
+ /**
+ * chat/messages/create-to-user
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ post: operations['chat___messages___create-to-user'];
+ };
+ '/chat/messages/delete': {
+ /**
+ * chat/messages/delete
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ post: operations['chat___messages___delete'];
+ };
+ '/chat/messages/react': {
+ /**
+ * chat/messages/react
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ post: operations['chat___messages___react'];
+ };
+ '/chat/messages/room-timeline': {
+ /**
+ * chat/messages/room-timeline
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:chat*
+ */
+ post: operations['chat___messages___room-timeline'];
+ };
+ '/chat/messages/search': {
+ /**
+ * chat/messages/search
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:chat*
+ */
+ post: operations['chat___messages___search'];
+ };
+ '/chat/messages/show': {
+ /**
+ * chat/messages/show
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:chat*
+ */
+ post: operations['chat___messages___show'];
+ };
+ '/chat/messages/user-timeline': {
+ /**
+ * chat/messages/user-timeline
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:chat*
+ */
+ post: operations['chat___messages___user-timeline'];
+ };
+ '/chat/rooms/create': {
+ /**
+ * chat/rooms/create
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ post: operations['chat___rooms___create'];
+ };
+ '/chat/rooms/delete': {
+ /**
+ * chat/rooms/delete
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ post: operations['chat___rooms___delete'];
+ };
+ '/chat/rooms/invitations/create': {
+ /**
+ * chat/rooms/invitations/create
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ post: operations['chat___rooms___invitations___create'];
+ };
+ '/chat/rooms/invitations/ignore': {
+ /**
+ * chat/rooms/invitations/ignore
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ post: operations['chat___rooms___invitations___ignore'];
+ };
+ '/chat/rooms/invitations/inbox': {
+ /**
+ * chat/rooms/invitations/inbox
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:chat*
+ */
+ post: operations['chat___rooms___invitations___inbox'];
+ };
+ '/chat/rooms/join': {
+ /**
+ * chat/rooms/join
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ post: operations['chat___rooms___join'];
+ };
+ '/chat/rooms/joining': {
+ /**
+ * chat/rooms/joining
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:chat*
+ */
+ post: operations['chat___rooms___joining'];
+ };
+ '/chat/rooms/leave': {
+ /**
+ * chat/rooms/leave
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ post: operations['chat___rooms___leave'];
+ };
+ '/chat/rooms/members': {
+ /**
+ * chat/rooms/members
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ post: operations['chat___rooms___members'];
+ };
+ '/chat/rooms/mute': {
+ /**
+ * chat/rooms/mute
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ post: operations['chat___rooms___mute'];
+ };
+ '/chat/rooms/owned': {
+ /**
+ * chat/rooms/owned
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:chat*
+ */
+ post: operations['chat___rooms___owned'];
+ };
+ '/chat/rooms/show': {
+ /**
+ * chat/rooms/show
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:chat*
+ */
+ post: operations['chat___rooms___show'];
+ };
+ '/chat/rooms/update': {
+ /**
+ * chat/rooms/update
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ post: operations['chat___rooms___update'];
+ };
'/clips/add-note': {
/**
* clips/add-note
@@ -2470,15 +2668,6 @@ export type paths = {
*/
post: operations['i___pin'];
};
- '/i/read-all-unread-notes': {
- /**
- * i/read-all-unread-notes
- * @description No description provided.
- *
- * **Credential required**: *Yes* / **Permission**: *write:account*
- */
- post: operations['i___read-all-unread-notes'];
- };
'/i/read-announcement': {
/**
* i/read-announcement
@@ -3848,6 +4037,8 @@ export type components = {
followingVisibility: 'public' | 'followers' | 'private';
/** @enum {string} */
followersVisibility: 'public' | 'followers' | 'private';
+ /** @enum {string} */
+ chatScope: 'everyone' | 'following' | 'followers' | 'mutual' | 'none';
roles: components['schemas']['RoleLite'][];
followedMessage?: string | null;
memo: string | null;
@@ -3894,6 +4085,7 @@ export type components = {
unreadAnnouncements: components['schemas']['Announcement'][];
hasUnreadAntenna: boolean;
hasUnreadChannel: boolean;
+ hasUnreadChatMessages: boolean;
hasUnreadNotification: boolean;
hasPendingReceivedFollowRequest: boolean;
unreadNotificationsCount: number;
@@ -4947,6 +5139,7 @@ export type components = {
canImportFollowing: boolean;
canImportMuting: boolean;
canImportUserLists: boolean;
+ canChat: boolean;
};
ReversiGameLite: {
/** Format: id */
@@ -5146,6 +5339,69 @@ export type components = {
systemWebhookId?: string;
systemWebhook?: components['schemas']['SystemWebhook'];
};
+ ChatMessage: {
+ id: string;
+ /** Format: date-time */
+ createdAt: string;
+ fromUserId: string;
+ fromUser: components['schemas']['UserLite'];
+ toUserId?: string | null;
+ toUser?: components['schemas']['UserLite'] | null;
+ toRoomId?: string | null;
+ toRoom?: components['schemas']['ChatRoom'] | null;
+ text?: string | null;
+ fileId?: string | null;
+ file?: components['schemas']['DriveFile'] | null;
+ isRead?: boolean;
+ reactions: ({
+ reaction: string;
+ user?: components['schemas']['UserLite'] | null;
+ })[];
+ };
+ ChatMessageLite: {
+ id: string;
+ /** Format: date-time */
+ createdAt: string;
+ fromUserId: string;
+ fromUser?: components['schemas']['UserLite'];
+ toUserId?: string | null;
+ toRoomId?: string | null;
+ text?: string | null;
+ fileId?: string | null;
+ file?: components['schemas']['DriveFile'] | null;
+ reactions: ({
+ reaction: string;
+ user?: components['schemas']['UserLite'] | null;
+ })[];
+ };
+ ChatRoom: {
+ id: string;
+ /** Format: date-time */
+ createdAt: string;
+ ownerId: string;
+ owner: components['schemas']['UserLite'];
+ name: string;
+ description: string;
+ isMuted?: boolean;
+ };
+ ChatRoomInvitation: {
+ id: string;
+ /** Format: date-time */
+ createdAt: string;
+ userId: string;
+ user: components['schemas']['UserLite'];
+ roomId: string;
+ room: components['schemas']['ChatRoom'];
+ };
+ ChatRoomMembership: {
+ id: string;
+ /** Format: date-time */
+ createdAt: string;
+ userId: string;
+ user?: components['schemas']['UserLite'];
+ roomId: string;
+ room?: components['schemas']['ChatRoom'];
+ };
};
responses: never;
parameters: never;
@@ -13670,6 +13926,1267 @@ export type operations = {
};
};
/**
+ * chat/history
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:chat*
+ */
+ chat___history: {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** @default 10 */
+ limit?: number;
+ /** @default false */
+ room?: boolean;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': components['schemas']['ChatMessage'][];
+ };
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
+ * chat/messages/create-to-room
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ 'chat___messages___create-to-room': {
+ requestBody: {
+ content: {
+ 'application/json': {
+ text?: string | null;
+ /** Format: misskey:id */
+ fileId?: string;
+ /** Format: misskey:id */
+ toRoomId: string;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': components['schemas']['ChatMessageLite'];
+ };
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Too many requests */
+ 429: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
+ * chat/messages/create-to-user
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ 'chat___messages___create-to-user': {
+ requestBody: {
+ content: {
+ 'application/json': {
+ text?: string | null;
+ /** Format: misskey:id */
+ fileId?: string;
+ /** Format: misskey:id */
+ toUserId: string;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': components['schemas']['ChatMessageLite'];
+ };
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Too many requests */
+ 429: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
+ * chat/messages/delete
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ chat___messages___delete: {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** Format: misskey:id */
+ messageId: string;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': unknown;
+ };
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
+ * chat/messages/react
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ chat___messages___react: {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** Format: misskey:id */
+ messageId: string;
+ reaction: string;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': unknown;
+ };
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
+ * chat/messages/room-timeline
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:chat*
+ */
+ 'chat___messages___room-timeline': {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** @default 10 */
+ limit?: number;
+ /** Format: misskey:id */
+ sinceId?: string;
+ /** Format: misskey:id */
+ untilId?: string;
+ /** Format: misskey:id */
+ roomId: string;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': components['schemas']['ChatMessageLite'][];
+ };
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
+ * chat/messages/search
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:chat*
+ */
+ chat___messages___search: {
+ requestBody: {
+ content: {
+ 'application/json': {
+ query: string;
+ /** @default 10 */
+ limit?: number;
+ /** Format: misskey:id */
+ userId?: string | null;
+ /** Format: misskey:id */
+ roomId?: string | null;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': components['schemas']['ChatMessage'][];
+ };
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
+ * chat/messages/show
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:chat*
+ */
+ chat___messages___show: {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** Format: misskey:id */
+ messageId: string;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': components['schemas']['ChatMessage'];
+ };
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
+ * chat/messages/user-timeline
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:chat*
+ */
+ 'chat___messages___user-timeline': {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** @default 10 */
+ limit?: number;
+ /** Format: misskey:id */
+ sinceId?: string;
+ /** Format: misskey:id */
+ untilId?: string;
+ /** Format: misskey:id */
+ userId: string;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': components['schemas']['ChatMessageLite'][];
+ };
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
+ * chat/rooms/create
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ chat___rooms___create: {
+ requestBody: {
+ content: {
+ 'application/json': {
+ name: string;
+ description?: string;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': components['schemas']['ChatRoom'];
+ };
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Too many requests */
+ 429: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
+ * chat/rooms/delete
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ chat___rooms___delete: {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** Format: misskey:id */
+ roomId: string;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': unknown;
+ };
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
+ * chat/rooms/invitations/create
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ chat___rooms___invitations___create: {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** Format: misskey:id */
+ roomId: string;
+ /** Format: misskey:id */
+ userId: string;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': components['schemas']['ChatRoomInvitation'];
+ };
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Too many requests */
+ 429: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
+ * chat/rooms/invitations/ignore
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ chat___rooms___invitations___ignore: {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** Format: misskey:id */
+ roomId: string;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': unknown;
+ };
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
+ * chat/rooms/invitations/inbox
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:chat*
+ */
+ chat___rooms___invitations___inbox: {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** @default 30 */
+ limit?: number;
+ /** Format: misskey:id */
+ sinceId?: string;
+ /** Format: misskey:id */
+ untilId?: string;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': components['schemas']['ChatRoomInvitation'][];
+ };
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
+ * chat/rooms/join
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ chat___rooms___join: {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** Format: misskey:id */
+ roomId: string;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': unknown;
+ };
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
+ * chat/rooms/joining
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:chat*
+ */
+ chat___rooms___joining: {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** @default 30 */
+ limit?: number;
+ /** Format: misskey:id */
+ sinceId?: string;
+ /** Format: misskey:id */
+ untilId?: string;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': components['schemas']['ChatRoomMembership'][];
+ };
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
+ * chat/rooms/leave
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ chat___rooms___leave: {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** Format: misskey:id */
+ roomId: string;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': unknown;
+ };
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
+ * chat/rooms/members
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ chat___rooms___members: {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** Format: misskey:id */
+ roomId: string;
+ /** @default 30 */
+ limit?: number;
+ /** Format: misskey:id */
+ sinceId?: string;
+ /** Format: misskey:id */
+ untilId?: string;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': components['schemas']['ChatRoomMembership'][];
+ };
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
+ * chat/rooms/mute
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ chat___rooms___mute: {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** Format: misskey:id */
+ roomId: string;
+ mute: boolean;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': unknown;
+ };
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
+ * chat/rooms/owned
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:chat*
+ */
+ chat___rooms___owned: {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** @default 30 */
+ limit?: number;
+ /** Format: misskey:id */
+ sinceId?: string;
+ /** Format: misskey:id */
+ untilId?: string;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': components['schemas']['ChatRoom'][];
+ };
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
+ * chat/rooms/show
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:chat*
+ */
+ chat___rooms___show: {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** Format: misskey:id */
+ roomId: string;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': components['schemas']['ChatRoom'];
+ };
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
+ * chat/rooms/update
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:chat*
+ */
+ chat___rooms___update: {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** Format: misskey:id */
+ roomId: string;
+ name?: string;
+ description?: string;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': components['schemas']['ChatRoom'];
+ };
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
* clips/add-note
* @description No description provided.
*
@@ -20280,50 +21797,6 @@ export type operations = {
};
};
/**
- * i/read-all-unread-notes
- * @description No description provided.
- *
- * **Credential required**: *Yes* / **Permission**: *write:account*
- */
- 'i___read-all-unread-notes': {
- responses: {
- /** @description OK (without any results) */
- 204: {
- content: never;
- };
- /** @description Client error */
- 400: {
- content: {
- 'application/json': components['schemas']['Error'];
- };
- };
- /** @description Authentication error */
- 401: {
- content: {
- 'application/json': components['schemas']['Error'];
- };
- };
- /** @description Forbidden error */
- 403: {
- content: {
- 'application/json': components['schemas']['Error'];
- };
- };
- /** @description I'm Ai */
- 418: {
- content: {
- 'application/json': components['schemas']['Error'];
- };
- };
- /** @description Internal server error */
- 500: {
- content: {
- 'application/json': components['schemas']['Error'];
- };
- };
- };
- };
- /**
* i/read-announcement
* @description No description provided.
*
@@ -21089,6 +22562,8 @@ export type operations = {
followingVisibility?: 'public' | 'followers' | 'private';
/** @enum {string} */
followersVisibility?: 'public' | 'followers' | 'private';
+ /** @enum {string} */
+ chatScope?: 'everyone' | 'followers' | 'following' | 'mutual' | 'none';
/** Format: misskey:id */
pinnedPageId?: string | null;
mutedWords?: (string[] | string)[];
diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts
index c5911a70eb..9d0fe5e781 100644
--- a/packages/misskey-js/src/consts.ts
+++ b/packages/misskey-js/src/consts.ts
@@ -37,8 +37,8 @@ export const permissions = [
'write:favorites',
'read:following',
'write:following',
- 'read:messaging',
- 'write:messaging',
+ 'read:messaging', // deprecated
+ 'write:messaging', // deprecated
'read:mutes',
'write:mutes',
'write:notes',
@@ -110,6 +110,8 @@ export const permissions = [
'read:clip-favorite',
'read:federation',
'write:report-abuse',
+ 'write:chat',
+ 'read:chat',
] as const;
export const moderationLogTypes = [
diff --git a/packages/misskey-js/src/streaming.types.ts b/packages/misskey-js/src/streaming.types.ts
index 26a50f9fa4..8e5438eeb6 100644
--- a/packages/misskey-js/src/streaming.types.ts
+++ b/packages/misskey-js/src/streaming.types.ts
@@ -1,5 +1,6 @@
import {
Antenna,
+ ChatMessage,
DriveFile,
DriveFolder,
Note,
@@ -46,13 +47,9 @@ export type Channels = {
urlUploadFinished: (payload: { marker: string; file: DriveFile; }) => void;
readAllNotifications: () => void;
unreadNotification: (payload: Notification) => void;
- unreadMention: (payload: Note['id']) => void;
- readAllUnreadMentions: () => void;
notificationFlushed: () => void;
- unreadSpecifiedNote: (payload: Note['id']) => void;
- readAllUnreadSpecifiedNotes: () => void;
- readAllAntennas: () => void;
unreadAntenna: (payload: Antenna) => void;
+ newChatMessage: (payload: ChatMessage) => void;
readAllAnnouncements: () => void;
myTokenRegenerated: () => void;
signin: (payload: Signin) => void;
diff --git a/packages/misskey-js/test/streaming.ts b/packages/misskey-js/test/streaming.ts
index 06b55cd8af..7e784cd20c 100644
--- a/packages/misskey-js/test/streaming.ts
+++ b/packages/misskey-js/test/streaming.ts
@@ -42,26 +42,26 @@ describe('Streaming', () => {
test('useChannel with parameters', async () => {
const server = new WS('wss://misskey.test/streaming');
const stream = new Stream('https://misskey.test', { token: 'TOKEN' });
- const messagingChannelReceived: any[] = [];
- const messaging = stream.useChannel('messaging', { otherparty: 'aaa' });
- messaging.on('message', payload => {
- messagingChannelReceived.push(payload);
+ const chatChannelReceived: any[] = [];
+ const chat = stream.useChannel('chat', { other: 'aaa' });
+ chat.on('message', payload => {
+ chatChannelReceived.push(payload);
});
const ws = await server.connected;
expect(new URLSearchParams(new URL(ws.url).search).get('i')).toEqual('TOKEN');
const msg = JSON.parse(await server.nextMessage as string);
- const messagingChannelId = msg.body.id;
+ const chatChannelId = msg.body.id;
expect(msg.type).toEqual('connect');
- expect(msg.body.channel).toEqual('messaging');
- expect(msg.body.params).toEqual({ otherparty: 'aaa' });
- expect(messagingChannelId != null).toEqual(true);
+ expect(msg.body.channel).toEqual('chat');
+ expect(msg.body.params).toEqual({ other: 'aaa' });
+ expect(chatChannelId != null).toEqual(true);
server.send(JSON.stringify({
type: 'channel',
body: {
- id: messagingChannelId,
+ id: chatChannelId,
type: 'message',
body: {
id: 'foo'
@@ -69,7 +69,7 @@ describe('Streaming', () => {
}
}));
- expect(messagingChannelReceived[0]).toEqual({
+ expect(chatChannelReceived[0]).toEqual({
id: 'foo'
});
@@ -81,20 +81,20 @@ describe('Streaming', () => {
const server = new WS('wss://misskey.test/streaming');
const stream = new Stream('https://misskey.test', { token: 'TOKEN' });
- stream.useChannel('messaging', { otherparty: 'aaa' });
- stream.useChannel('messaging', { otherparty: 'bbb' });
+ stream.useChannel('chat', { other: 'aaa' });
+ stream.useChannel('chat', { other: 'bbb' });
const ws = await server.connected;
expect(new URLSearchParams(new URL(ws.url).search).get('i')).toEqual('TOKEN');
const msg = JSON.parse(await server.nextMessage as string);
- const messagingChannelId = msg.body.id;
+ const chatChannelId = msg.body.id;
const msg2 = JSON.parse(await server.nextMessage as string);
- const messagingChannelId2 = msg2.body.id;
+ const chatChannelId2 = msg2.body.id;
- expect(messagingChannelId != null).toEqual(true);
- expect(messagingChannelId2 != null).toEqual(true);
- expect(messagingChannelId).not.toEqual(messagingChannelId2);
+ expect(chatChannelId != null).toEqual(true);
+ expect(chatChannelId2 != null).toEqual(true);
+ expect(chatChannelId).not.toEqual(chatChannelId2);
stream.close();
server.close();
@@ -104,8 +104,8 @@ describe('Streaming', () => {
const server = new WS('wss://misskey.test/streaming');
const stream = new Stream('https://misskey.test', { token: 'TOKEN' });
- const messaging = stream.useChannel('messaging', { otherparty: 'aaa' });
- messaging.send('read', { id: 'aaa' });
+ const chat = stream.useChannel('chat', { other: 'aaa' });
+ chat.send('read', { id: 'aaa' });
const ws = await server.connected;
expect(new URLSearchParams(new URL(ws.url).search).get('i')).toEqual('TOKEN');