diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 0c6f969f01..1ec87be63e 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -430,15 +430,24 @@ markAsReadAllTalkMessages: "すべてのチャットを既読にする" help: "ヘルプ" inputMessageHere: "ここにメッセージを入力" close: "閉じる" +group: "グループ" +groups: "グループ" +createGroup: "グループを作成" +ownedGroups: "所有グループ" +joinedGroups: "参加しているグループ" invites: "招待" +groupName: "グループ名" members: "メンバー" transfer: "譲渡" +messagingWithUser: "ユーザーとチャット" +messagingWithGroup: "グループでチャット" title: "タイトル" text: "テキスト" enable: "有効にする" next: "次" retype: "再入力" noteOf: "{user}のノート" +inviteToGroup: "グループに招待" quoteAttached: "引用付き" quoteQuestion: "引用として添付しますか?" noMessagesYet: "まだチャットはありません" @@ -464,10 +473,13 @@ tapSecurityKey: "セキュリティキーにタッチ" or: "もしくは" language: "言語" uiLanguage: "UIの表示言語" +groupInvited: "グループに招待されました" aboutX: "{x}について" emojiStyle: "絵文字のスタイル" native: "ネイティブ" disableDrawer: "メニューをドロワーで表示しない" +youHaveNoGroups: "グループがありません" +joinOrCreateGroup: "既存のグループに招待してもらうか、新しくグループを作成してください。" noHistory: "履歴はありません" signinHistory: "ログイン履歴" enableAdvancedMfm: "高度なMFMを有効にする" @@ -842,6 +854,8 @@ deleteAccountConfirm: "アカウントが削除されます。よろしいです incorrectPassword: "パスワードが間違っています。" voteConfirm: "「{choice}」に投票しますか?" hide: "隠す" +leaveGroup: "グループから抜ける" +leaveGroupConfirm: "「{name}」から抜けますか?" useDrawerReactionPickerForMobile: "モバイルデバイスのときドロワーで表示" welcomeBackWithName: "おかえりなさい、{name}さん" clickToFinishEmailVerification: "[{ok}]を押して、メールアドレスの確認を完了してください。" @@ -1673,6 +1687,7 @@ _antennaSources: homeTimeline: "フォローしているユーザーのノート" users: "指定した一人または複数のユーザーのノート" userList: "指定したリストのユーザーのノート" + userGroup: "指定したグループのユーザーのノート" _weekday: sunday: "日曜日" @@ -1902,9 +1917,12 @@ _notification: youGotReply: "{name}からのリプライ" youGotQuote: "{name}による引用" youRenoted: "{name}がRenoteしました" + youGotMessagingMessageFromUser: "{name}からのチャットがあります" + youGotMessagingMessageFromGroup: "{name}のチャットがあります" youWereFollowed: "フォローされました" youReceivedFollowRequest: "フォローリクエストが来ました" yourFollowRequestAccepted: "フォローリクエストが承認されました" + youWereInvitedToGroup: "{userName}があなたをグループに招待しました" pollEnded: "アンケートの結果が出ました" unreadAntennaNote: "アンテナ {name}" emptyPushNotificationMessage: "プッシュ通知の更新をしました" @@ -1921,6 +1939,7 @@ _notification: pollEnded: "アンケートが終了" receiveFollowRequest: "フォロー申請を受け取った" followRequestAccepted: "フォローが受理された" + groupInvited: "グループに招待された" app: "連携アプリからの通知" _actions: diff --git a/packages/backend/migration/1676434944993-drop-group.js b/packages/backend/migration/1676434944993-drop-group.js deleted file mode 100644 index 043db83b0b..0000000000 --- a/packages/backend/migration/1676434944993-drop-group.js +++ /dev/null @@ -1,47 +0,0 @@ -export class dropGroup1676434944993 { - name = 'dropGroup1676434944993' - - async up(queryRunner) { - await queryRunner.query(`ALTER TABLE "antenna" DROP CONSTRAINT "FK_ccbf5a8c0be4511133dcc50ddeb"`); - await queryRunner.query(`ALTER TABLE "notification" DROP CONSTRAINT "FK_8fe87814e978053a53b1beb7e98"`); - await queryRunner.query(`ALTER TABLE "antenna" DROP COLUMN "userGroupJoiningId"`); - await queryRunner.query(`ALTER TABLE "notification" DROP COLUMN "userGroupInvitationId"`); - await queryRunner.query(`ALTER TYPE "public"."antenna_src_enum" RENAME TO "antenna_src_enum_old"`); - await queryRunner.query(`CREATE TYPE "public"."antenna_src_enum" AS ENUM('home', 'all', 'users', 'list')`); - await queryRunner.query(`ALTER TABLE "antenna" ALTER COLUMN "src" TYPE "public"."antenna_src_enum" USING "src"::"text"::"public"."antenna_src_enum"`); - await queryRunner.query(`DROP TYPE "public"."antenna_src_enum_old"`); - await queryRunner.query(`ALTER TYPE "public"."notification_type_enum" RENAME TO "notification_type_enum_old"`); - await queryRunner.query(`CREATE TYPE "public"."notification_type_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'achievementEarned', 'app')`); - await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum" USING "type"::"text"::"public"."notification_type_enum"`); - await queryRunner.query(`DROP TYPE "public"."notification_type_enum_old"`); - await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "emailNotificationTypes" SET DEFAULT '["follow","receiveFollowRequest"]'`); - await queryRunner.query(`ALTER TYPE "public"."user_profile_mutingnotificationtypes_enum" RENAME TO "user_profile_mutingnotificationtypes_enum_old"`); - await queryRunner.query(`CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'achievementEarned', 'app')`); - await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" DROP DEFAULT`); - await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" TYPE "public"."user_profile_mutingnotificationtypes_enum"[] USING "mutingNotificationTypes"::"text"::"public"."user_profile_mutingnotificationtypes_enum"[]`); - await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" SET DEFAULT '{}'`); - await queryRunner.query(`DROP TYPE "public"."user_profile_mutingnotificationtypes_enum_old"`); - } - - async down(queryRunner) { - await queryRunner.query(`CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'achievementEarned', 'app')`); - await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" DROP DEFAULT`); - await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" TYPE "public"."user_profile_mutingnotificationtypes_enum_old"[] USING "mutingNotificationTypes"::"text"::"public"."user_profile_mutingnotificationtypes_enum_old"[]`); - await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" SET DEFAULT '{}'`); - await queryRunner.query(`DROP TYPE "public"."user_profile_mutingnotificationtypes_enum"`); - await queryRunner.query(`ALTER TYPE "public"."user_profile_mutingnotificationtypes_enum_old" RENAME TO "user_profile_mutingnotificationtypes_enum"`); - await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "emailNotificationTypes" SET DEFAULT '["follow", "receiveFollowRequest", "groupInvited"]'`); - await queryRunner.query(`CREATE TYPE "public"."notification_type_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'achievementEarned', 'app')`); - await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum_old" USING "type"::"text"::"public"."notification_type_enum_old"`); - await queryRunner.query(`DROP TYPE "public"."notification_type_enum"`); - await queryRunner.query(`ALTER TYPE "public"."notification_type_enum_old" RENAME TO "notification_type_enum"`); - await queryRunner.query(`CREATE TYPE "public"."antenna_src_enum_old" AS ENUM('home', 'all', 'users', 'list', 'group')`); - await queryRunner.query(`ALTER TABLE "antenna" ALTER COLUMN "src" TYPE "public"."antenna_src_enum_old" USING "src"::"text"::"public"."antenna_src_enum_old"`); - await queryRunner.query(`DROP TYPE "public"."antenna_src_enum"`); - await queryRunner.query(`ALTER TYPE "public"."antenna_src_enum_old" RENAME TO "antenna_src_enum"`); - await queryRunner.query(`ALTER TABLE "notification" ADD "userGroupInvitationId" character varying(32)`); - await queryRunner.query(`ALTER TABLE "antenna" ADD "userGroupJoiningId" character varying(32)`); - await queryRunner.query(`ALTER TABLE "notification" ADD CONSTRAINT "FK_8fe87814e978053a53b1beb7e98" FOREIGN KEY ("userGroupInvitationId") REFERENCES "user_group_invitation"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE "antenna" ADD CONSTRAINT "FK_ccbf5a8c0be4511133dcc50ddeb" FOREIGN KEY ("userGroupJoiningId") REFERENCES "user_group_joining"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); - } -} diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index 0e72545934..a71327e947 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -12,7 +12,7 @@ import { PushNotificationService } from '@/core/PushNotificationService.js'; import * as Acct from '@/misc/acct.js'; import type { Packed } from '@/misc/schema.js'; import { DI } from '@/di-symbols.js'; -import type { MutingsRepository, NotesRepository, AntennaNotesRepository, AntennasRepository, UserListJoiningsRepository } from '@/models/index.js'; +import type { MutingsRepository, NotesRepository, AntennaNotesRepository, AntennasRepository, UserGroupJoiningsRepository, UserListJoiningsRepository } from '@/models/index.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { StreamMessages } from '@/server/api/stream/types.js'; @@ -39,6 +39,9 @@ export class AntennaService implements OnApplicationShutdown { @Inject(DI.antennasRepository) private antennasRepository: AntennasRepository, + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + @Inject(DI.userListJoiningsRepository) private userListJoiningsRepository: UserListJoiningsRepository, @@ -157,6 +160,14 @@ export class AntennaService implements OnApplicationShutdown { })).map(x => x.userId); if (!listUsers.includes(note.userId)) return false; + } else if (antenna.src === 'group') { + const joining = await this.userGroupJoiningsRepository.findOneByOrFail({ id: antenna.userGroupJoiningId! }); + + const groupUsers = (await this.userGroupJoiningsRepository.findBy({ + userGroupId: joining.userGroupId, + })).map(x => x.userId); + + if (!groupUsers.includes(note.userId)) return false; } else if (antenna.src === 'users') { const accts = antenna.users.map(x => { const { username, host } = Acct.parse(x); diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 60c7be5016..0524585ca6 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -91,6 +91,8 @@ import { PageEntityService } from './entities/PageEntityService.js'; import { PageLikeEntityService } from './entities/PageLikeEntityService.js'; import { SigninEntityService } from './entities/SigninEntityService.js'; import { UserEntityService } from './entities/UserEntityService.js'; +import { UserGroupEntityService } from './entities/UserGroupEntityService.js'; +import { UserGroupInvitationEntityService } from './entities/UserGroupInvitationEntityService.js'; import { UserListEntityService } from './entities/UserListEntityService.js'; import { FlashEntityService } from './entities/FlashEntityService.js'; import { FlashLikeEntityService } from './entities/FlashLikeEntityService.js'; @@ -212,6 +214,8 @@ const $PageEntityService: Provider = { provide: 'PageEntityService', useExisting const $PageLikeEntityService: Provider = { provide: 'PageLikeEntityService', useExisting: PageLikeEntityService }; const $SigninEntityService: Provider = { provide: 'SigninEntityService', useExisting: SigninEntityService }; const $UserEntityService: Provider = { provide: 'UserEntityService', useExisting: UserEntityService }; +const $UserGroupEntityService: Provider = { provide: 'UserGroupEntityService', useExisting: UserGroupEntityService }; +const $UserGroupInvitationEntityService: Provider = { provide: 'UserGroupInvitationEntityService', useExisting: UserGroupInvitationEntityService }; const $UserListEntityService: Provider = { provide: 'UserListEntityService', useExisting: UserListEntityService }; const $FlashEntityService: Provider = { provide: 'FlashEntityService', useExisting: FlashEntityService }; const $FlashLikeEntityService: Provider = { provide: 'FlashLikeEntityService', useExisting: FlashLikeEntityService }; @@ -334,6 +338,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting PageLikeEntityService, SigninEntityService, UserEntityService, + UserGroupEntityService, + UserGroupInvitationEntityService, UserListEntityService, FlashEntityService, FlashLikeEntityService, @@ -451,6 +457,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $PageLikeEntityService, $SigninEntityService, $UserEntityService, + $UserGroupEntityService, + $UserGroupInvitationEntityService, $UserListEntityService, $FlashEntityService, $FlashLikeEntityService, @@ -568,6 +576,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting PageLikeEntityService, SigninEntityService, UserEntityService, + UserGroupEntityService, + UserGroupInvitationEntityService, UserListEntityService, FlashEntityService, FlashLikeEntityService, @@ -684,6 +694,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $PageLikeEntityService, $SigninEntityService, $UserEntityService, + $UserGroupEntityService, + $UserGroupInvitationEntityService, $UserListEntityService, $FlashEntityService, $FlashLikeEntityService, diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index d97e192b53..f70347c46a 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -3,6 +3,7 @@ import Redis from 'ioredis'; import type { User } from '@/models/entities/User.js'; import type { Note } from '@/models/entities/Note.js'; import type { UserList } from '@/models/entities/UserList.js'; +import type { UserGroup } from '@/models/entities/UserGroup.js'; import type { Antenna } from '@/models/entities/Antenna.js'; import type { Channel } from '@/models/entities/Channel.js'; import type { diff --git a/packages/backend/src/core/entities/AntennaEntityService.ts b/packages/backend/src/core/entities/AntennaEntityService.ts index 1ad4e4e912..bc79ce26aa 100644 --- a/packages/backend/src/core/entities/AntennaEntityService.ts +++ b/packages/backend/src/core/entities/AntennaEntityService.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { AntennaNotesRepository, AntennasRepository } from '@/models/index.js'; +import type { AntennaNotesRepository, AntennasRepository, UserGroupJoiningsRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { Packed } from '@/misc/schema.js'; import type { Antenna } from '@/models/entities/Antenna.js'; @@ -14,6 +14,9 @@ export class AntennaEntityService { @Inject(DI.antennaNotesRepository) private antennaNotesRepository: AntennaNotesRepository, + + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, ) { } @@ -24,6 +27,7 @@ export class AntennaEntityService { const antenna = typeof src === 'object' ? src : await this.antennasRepository.findOneByOrFail({ id: src }); const hasUnreadNote = (await this.antennaNotesRepository.findOneBy({ antennaId: antenna.id, read: false })) != null; + const userGroupJoining = antenna.userGroupJoiningId ? await this.userGroupJoiningsRepository.findOneBy({ id: antenna.userGroupJoiningId }) : null; return { id: antenna.id, @@ -33,6 +37,7 @@ export class AntennaEntityService { excludeKeywords: antenna.excludeKeywords, src: antenna.src, userListId: antenna.userListId, + userGroupId: userGroupJoining ? userGroupJoining.userGroupId : null, users: antenna.users, caseSensitive: antenna.caseSensitive, notify: antenna.notify, diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index 34ff52ede8..4140b3f35e 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -13,11 +13,13 @@ import type { OnModuleInit } from '@nestjs/common'; import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { UserEntityService } from './UserEntityService.js'; import type { NoteEntityService } from './NoteEntityService.js'; +import type { UserGroupInvitationEntityService } from './UserGroupInvitationEntityService.js'; @Injectable() export class NotificationEntityService implements OnModuleInit { private userEntityService: UserEntityService; private noteEntityService: NoteEntityService; + private userGroupInvitationEntityService: UserGroupInvitationEntityService; private customEmojiService: CustomEmojiService; constructor( @@ -34,6 +36,7 @@ export class NotificationEntityService implements OnModuleInit { //private userEntityService: UserEntityService, //private noteEntityService: NoteEntityService, + //private userGroupInvitationEntityService: UserGroupInvitationEntityService, //private customEmojiService: CustomEmojiService, ) { } @@ -41,6 +44,7 @@ export class NotificationEntityService implements OnModuleInit { onModuleInit() { this.userEntityService = this.moduleRef.get('UserEntityService'); this.noteEntityService = this.moduleRef.get('NoteEntityService'); + this.userGroupInvitationEntityService = this.moduleRef.get('UserGroupInvitationEntityService'); this.customEmojiService = this.moduleRef.get('CustomEmojiService'); } @@ -107,6 +111,9 @@ export class NotificationEntityService implements OnModuleInit { _hint_: options._hintForEachNotes_, }), } : {}), + ...(notification.type === 'groupInvited' ? { + invitation: this.userGroupInvitationEntityService.pack(notification.userGroupInvitationId!), + } : {}), ...(notification.type === 'achievementEarned' ? { achievement: notification.achievement, } : {}), diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 9553d56246..19bae443c8 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -12,7 +12,7 @@ import { Cache } from '@/misc/cache.js'; import type { Instance } from '@/models/entities/Instance.js'; import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js'; -import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, NotificationsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, AntennaNotesRepository, PagesRepository, UserProfile } from '@/models/index.js'; +import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, NotificationsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, UserGroupJoiningsRepository, AnnouncementsRepository, AntennaNotesRepository, PagesRepository, UserProfile } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import type { OnModuleInit } from '@nestjs/common'; @@ -102,6 +102,9 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.announcementReadsRepository) private announcementReadsRepository: AnnouncementReadsRepository, + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + @Inject(DI.announcementsRepository) private announcementsRepository: AnnouncementsRepository, diff --git a/packages/backend/src/core/entities/UserGroupEntityService.ts b/packages/backend/src/core/entities/UserGroupEntityService.ts new file mode 100644 index 0000000000..0674a76723 --- /dev/null +++ b/packages/backend/src/core/entities/UserGroupEntityService.ts @@ -0,0 +1,44 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { UserGroupJoiningsRepository, UserGroupsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { UserGroup } from '@/models/entities/UserGroup.js'; +import { UserEntityService } from './UserEntityService.js'; +import { bindThis } from '@/decorators.js'; + +@Injectable() +export class UserGroupEntityService { + constructor( + @Inject(DI.userGroupsRepository) + private userGroupsRepository: UserGroupsRepository, + + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + private userEntityService: UserEntityService, + ) { + } + + @bindThis + public async pack( + src: UserGroup['id'] | UserGroup, + ): Promise> { + const userGroup = typeof src === 'object' ? src : await this.userGroupsRepository.findOneByOrFail({ id: src }); + + const users = await this.userGroupJoiningsRepository.findBy({ + userGroupId: userGroup.id, + }); + + return { + id: userGroup.id, + createdAt: userGroup.createdAt.toISOString(), + name: userGroup.name, + ownerId: userGroup.userId, + userIds: users.map(x => x.userId), + }; + } +} + diff --git a/packages/backend/src/core/entities/UserGroupInvitationEntityService.ts b/packages/backend/src/core/entities/UserGroupInvitationEntityService.ts new file mode 100644 index 0000000000..0fba1426f4 --- /dev/null +++ b/packages/backend/src/core/entities/UserGroupInvitationEntityService.ts @@ -0,0 +1,42 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { UserGroupInvitationsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { UserGroupInvitation } from '@/models/entities/UserGroupInvitation.js'; +import { UserEntityService } from './UserEntityService.js'; +import { UserGroupEntityService } from './UserGroupEntityService.js'; +import { bindThis } from '@/decorators.js'; + +@Injectable() +export class UserGroupInvitationEntityService { + constructor( + @Inject(DI.userGroupInvitationsRepository) + private userGroupInvitationsRepository: UserGroupInvitationsRepository, + + private userGroupEntityService: UserGroupEntityService, + ) { + } + + @bindThis + public async pack( + src: UserGroupInvitation['id'] | UserGroupInvitation, + ) { + const invitation = typeof src === 'object' ? src : await this.userGroupInvitationsRepository.findOneByOrFail({ id: src }); + + return { + id: invitation.id, + group: await this.userGroupEntityService.pack(invitation.userGroup ?? invitation.userGroupId), + }; + } + + @bindThis + public packMany( + invitations: any[], + ) { + return Promise.all(invitations.map(x => this.pack(x))); + } +} + diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 05603093be..da21409541 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -24,6 +24,9 @@ export const DI = { userPublickeysRepository: Symbol('userPublickeysRepository'), userListsRepository: Symbol('userListsRepository'), userListJoiningsRepository: Symbol('userListJoiningsRepository'), + userGroupsRepository: Symbol('userGroupsRepository'), + userGroupJoiningsRepository: Symbol('userGroupJoiningsRepository'), + userGroupInvitationsRepository: Symbol('userGroupInvitationsRepository'), userNotePiningsRepository: Symbol('userNotePiningsRepository'), userIpsRepository: Symbol('userIpsRepository'), usedUsernamesRepository: Symbol('usedUsernamesRepository'), diff --git a/packages/backend/src/misc/schema.ts b/packages/backend/src/misc/schema.ts index 37b0d713e2..b3db19b338 100644 --- a/packages/backend/src/misc/schema.ts +++ b/packages/backend/src/misc/schema.ts @@ -19,6 +19,7 @@ import { packedBlockingSchema } from '@/models/schema/blocking.js'; import { packedNoteReactionSchema } from '@/models/schema/note-reaction.js'; import { packedHashtagSchema } from '@/models/schema/hashtag.js'; import { packedPageSchema } from '@/models/schema/page.js'; +import { packedUserGroupSchema } from '@/models/schema/user-group.js'; import { packedNoteFavoriteSchema } from '@/models/schema/note-favorite.js'; import { packedChannelSchema } from '@/models/schema/channel.js'; import { packedAntennaSchema } from '@/models/schema/antenna.js'; @@ -38,6 +39,7 @@ export const refs = { User: packedUserSchema, UserList: packedUserListSchema, + UserGroup: packedUserGroupSchema, App: packedAppSchema, Note: packedNoteSchema, NoteReaction: packedNoteReactionSchema, diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 311f875ba5..231dbb225b 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment } from './index.js'; +import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserGroup, UserGroupJoining, UserGroupInvitation, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment } from './index.js'; import type { DataSource } from 'typeorm'; import type { Provider } from '@nestjs/common'; @@ -118,6 +118,24 @@ const $userListJoiningsRepository: Provider = { inject: [DI.db], }; +const $userGroupsRepository: Provider = { + provide: DI.userGroupsRepository, + useFactory: (db: DataSource) => db.getRepository(UserGroup), + inject: [DI.db], +}; + +const $userGroupJoiningsRepository: Provider = { + provide: DI.userGroupJoiningsRepository, + useFactory: (db: DataSource) => db.getRepository(UserGroupJoining), + inject: [DI.db], +}; + +const $userGroupInvitationsRepository: Provider = { + provide: DI.userGroupInvitationsRepository, + useFactory: (db: DataSource) => db.getRepository(UserGroupInvitation), + inject: [DI.db], +}; + const $userNotePiningsRepository: Provider = { provide: DI.userNotePiningsRepository, useFactory: (db: DataSource) => db.getRepository(UserNotePining), @@ -411,6 +429,9 @@ const $roleAssignmentsRepository: Provider = { $userPublickeysRepository, $userListsRepository, $userListJoiningsRepository, + $userGroupsRepository, + $userGroupJoiningsRepository, + $userGroupInvitationsRepository, $userNotePiningsRepository, $userIpsRepository, $usedUsernamesRepository, @@ -477,6 +498,9 @@ const $roleAssignmentsRepository: Provider = { $userPublickeysRepository, $userListsRepository, $userListJoiningsRepository, + $userGroupsRepository, + $userGroupJoiningsRepository, + $userGroupInvitationsRepository, $userNotePiningsRepository, $userIpsRepository, $usedUsernamesRepository, diff --git a/packages/backend/src/models/entities/Antenna.ts b/packages/backend/src/models/entities/Antenna.ts index 5b2164ef17..860fd9cf55 100644 --- a/packages/backend/src/models/entities/Antenna.ts +++ b/packages/backend/src/models/entities/Antenna.ts @@ -2,6 +2,7 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typ import { id } from '../id.js'; import { User } from './User.js'; import { UserList } from './UserList.js'; +import { UserGroupJoining } from './UserGroupJoining.js'; @Entity() export class Antenna { @@ -32,8 +33,8 @@ export class Antenna { }) public name: string; - @Column('enum', { enum: ['home', 'all', 'users', 'list'] }) - public src: 'home' | 'all' | 'users' | 'list'; + @Column('enum', { enum: ['home', 'all', 'users', 'list', 'group'] }) + public src: 'home' | 'all' | 'users' | 'list' | 'group'; @Column({ ...id(), @@ -47,6 +48,18 @@ export class Antenna { @JoinColumn() public userList: UserList | null; + @Column({ + ...id(), + nullable: true, + }) + public userGroupJoiningId: UserGroupJoining['id'] | null; + + @ManyToOne(type => UserGroupJoining, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public userGroupJoining: UserGroupJoining | null; + @Column('varchar', { length: 1024, array: true, default: '{}', diff --git a/packages/backend/src/models/entities/Notification.ts b/packages/backend/src/models/entities/Notification.ts index 105f0d0407..66f131d1c0 100644 --- a/packages/backend/src/models/entities/Notification.ts +++ b/packages/backend/src/models/entities/Notification.ts @@ -4,6 +4,7 @@ import { id } from '../id.js'; import { User } from './User.js'; import { Note } from './Note.js'; import { FollowRequest } from './FollowRequest.js'; +import { UserGroupInvitation } from './UserGroupInvitation.js'; import { AccessToken } from './AccessToken.js'; @Entity() @@ -62,6 +63,7 @@ export class Notification { * pollEnded - 自分のアンケートもしくは自分が投票したアンケートが終了した * receiveFollowRequest - フォローリクエストされた * followRequestAccepted - 自分の送ったフォローリクエストが承認された + * groupInvited - グループに招待された * achievementEarned - 実績を獲得 * app - アプリ通知 */ @@ -106,6 +108,18 @@ export class Notification { @JoinColumn() public followRequest: FollowRequest | null; + @Column({ + ...id(), + nullable: true, + }) + public userGroupInvitationId: UserGroupInvitation['id'] | null; + + @ManyToOne(type => UserGroupInvitation, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public userGroupInvitation: UserGroupInvitation | null; + @Column('varchar', { length: 128, nullable: true, }) diff --git a/packages/backend/src/models/entities/UserGroup.ts b/packages/backend/src/models/entities/UserGroup.ts new file mode 100644 index 0000000000..328a1883cb --- /dev/null +++ b/packages/backend/src/models/entities/UserGroup.ts @@ -0,0 +1,46 @@ +import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; +import { id } from '../id.js'; +import { User } from './User.js'; + +@Entity() +export class UserGroup { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the UserGroup.', + }) + public createdAt: Date; + + @Column('varchar', { + length: 256, + }) + public name: string; + + @Index() + @Column({ + ...id(), + comment: 'The ID of owner.', + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Column('boolean', { + default: false, + }) + public isPrivate: boolean; + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/entities/UserGroupInvitation.ts b/packages/backend/src/models/entities/UserGroupInvitation.ts new file mode 100644 index 0000000000..e4aa3ccae1 --- /dev/null +++ b/packages/backend/src/models/entities/UserGroupInvitation.ts @@ -0,0 +1,42 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from '../id.js'; +import { User } from './User.js'; +import { UserGroup } from './UserGroup.js'; + +@Entity() +@Index(['userId', 'userGroupId'], { unique: true }) +export class UserGroupInvitation { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the UserGroupInvitation.', + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The user ID.', + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), + comment: 'The group ID.', + }) + public userGroupId: UserGroup['id']; + + @ManyToOne(type => UserGroup, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public userGroup: UserGroup | null; +} diff --git a/packages/backend/src/models/entities/UserGroupJoining.ts b/packages/backend/src/models/entities/UserGroupJoining.ts new file mode 100644 index 0000000000..fae7241525 --- /dev/null +++ b/packages/backend/src/models/entities/UserGroupJoining.ts @@ -0,0 +1,42 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from '../id.js'; +import { User } from './User.js'; +import { UserGroup } from './UserGroup.js'; + +@Entity() +@Index(['userId', 'userGroupId'], { unique: true }) +export class UserGroupJoining { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the UserGroupJoining.', + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The user ID.', + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), + comment: 'The group ID.', + }) + public userGroupId: UserGroup['id']; + + @ManyToOne(type => UserGroup, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public userGroup: UserGroup | null; +} diff --git a/packages/backend/src/models/entities/UserProfile.ts b/packages/backend/src/models/entities/UserProfile.ts index 029395ac39..1ff261cda3 100644 --- a/packages/backend/src/models/entities/UserProfile.ts +++ b/packages/backend/src/models/entities/UserProfile.ts @@ -71,7 +71,7 @@ export class UserProfile { public emailVerified: boolean; @Column('jsonb', { - default: ['follow', 'receiveFollowRequest'], + default: ['follow', 'receiveFollowRequest', 'groupInvited'], }) public emailNotificationTypes: string[]; diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index 25ed9b89d8..1494905277 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -46,6 +46,9 @@ import { Signin } from '@/models/entities/Signin.js'; import { SwSubscription } from '@/models/entities/SwSubscription.js'; import { UsedUsername } from '@/models/entities/UsedUsername.js'; import { User } from '@/models/entities/User.js'; +import { UserGroup } from '@/models/entities/UserGroup.js'; +import { UserGroupInvitation } from '@/models/entities/UserGroupInvitation.js'; +import { UserGroupJoining } from '@/models/entities/UserGroupJoining.js'; import { UserIp } from '@/models/entities/UserIp.js'; import { UserKeypair } from '@/models/entities/UserKeypair.js'; import { UserList } from '@/models/entities/UserList.js'; @@ -113,6 +116,9 @@ export { SwSubscription, UsedUsername, User, + UserGroup, + UserGroupInvitation, + UserGroupJoining, UserIp, UserKeypair, UserList, @@ -179,6 +185,9 @@ export type SigninsRepository = Repository; export type SwSubscriptionsRepository = Repository; export type UsedUsernamesRepository = Repository; export type UsersRepository = Repository; +export type UserGroupsRepository = Repository; +export type UserGroupInvitationsRepository = Repository; +export type UserGroupJoiningsRepository = Repository; export type UserIpsRepository = Repository; export type UserKeypairsRepository = Repository; export type UserListsRepository = Repository; diff --git a/packages/backend/src/models/schema/antenna.ts b/packages/backend/src/models/schema/antenna.ts index f0994e48f7..9cf522802c 100644 --- a/packages/backend/src/models/schema/antenna.ts +++ b/packages/backend/src/models/schema/antenna.ts @@ -42,13 +42,18 @@ export const packedAntennaSchema = { src: { type: 'string', optional: false, nullable: false, - enum: ['home', 'all', 'users', 'list'], + enum: ['home', 'all', 'users', 'list', 'group'], }, userListId: { type: 'string', optional: false, nullable: true, format: 'id', }, + userGroupId: { + type: 'string', + optional: false, nullable: true, + format: 'id', + }, users: { type: 'array', optional: false, nullable: false, diff --git a/packages/backend/src/models/schema/user-group.ts b/packages/backend/src/models/schema/user-group.ts new file mode 100644 index 0000000000..a73bf82bb8 --- /dev/null +++ b/packages/backend/src/models/schema/user-group.ts @@ -0,0 +1,34 @@ +export const packedUserGroupSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + ownerId: { + type: 'string', + nullable: false, optional: false, + format: 'id', + }, + userIds: { + type: 'array', + nullable: false, optional: true, + items: { + type: 'string', + nullable: false, optional: false, + format: 'id', + }, + }, + }, +} as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 3141cc8590..8cf259eb16 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -54,6 +54,9 @@ import { Signin } from '@/models/entities/Signin.js'; import { SwSubscription } from '@/models/entities/SwSubscription.js'; import { UsedUsername } from '@/models/entities/UsedUsername.js'; import { User } from '@/models/entities/User.js'; +import { UserGroup } from '@/models/entities/UserGroup.js'; +import { UserGroupInvitation } from '@/models/entities/UserGroupInvitation.js'; +import { UserGroupJoining } from '@/models/entities/UserGroupJoining.js'; import { UserIp } from '@/models/entities/UserIp.js'; import { UserKeypair } from '@/models/entities/UserKeypair.js'; import { UserList } from '@/models/entities/UserList.js'; @@ -133,6 +136,9 @@ export const entities = [ UserPublickey, UserList, UserListJoining, + UserGroup, + UserGroupJoining, + UserGroupInvitation, UserNotePining, UserSecurityKey, UsedUsername, diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 30101a2c60..933dcbebe6 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -211,6 +211,7 @@ import * as ep___i_signinHistory from './endpoints/i/signin-history.js'; import * as ep___i_unpin from './endpoints/i/unpin.js'; import * as ep___i_updateEmail from './endpoints/i/update-email.js'; import * as ep___i_update from './endpoints/i/update.js'; +import * as ep___i_userGroupInvites from './endpoints/i/user-group-invites.js'; import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js'; import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js'; import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js'; @@ -293,6 +294,18 @@ import * as ep___users_followers from './endpoints/users/followers.js'; import * as ep___users_following from './endpoints/users/following.js'; import * as ep___users_gallery_posts from './endpoints/users/gallery/posts.js'; import * as ep___users_getFrequentlyRepliedUsers from './endpoints/users/get-frequently-replied-users.js'; +import * as ep___users_groups_create from './endpoints/users/groups/create.js'; +import * as ep___users_groups_delete from './endpoints/users/groups/delete.js'; +import * as ep___users_groups_invitations_accept from './endpoints/users/groups/invitations/accept.js'; +import * as ep___users_groups_invitations_reject from './endpoints/users/groups/invitations/reject.js'; +import * as ep___users_groups_invite from './endpoints/users/groups/invite.js'; +import * as ep___users_groups_joined from './endpoints/users/groups/joined.js'; +import * as ep___users_groups_leave from './endpoints/users/groups/leave.js'; +import * as ep___users_groups_owned from './endpoints/users/groups/owned.js'; +import * as ep___users_groups_pull from './endpoints/users/groups/pull.js'; +import * as ep___users_groups_show from './endpoints/users/groups/show.js'; +import * as ep___users_groups_transfer from './endpoints/users/groups/transfer.js'; +import * as ep___users_groups_update from './endpoints/users/groups/update.js'; import * as ep___users_lists_create from './endpoints/users/lists/create.js'; import * as ep___users_lists_delete from './endpoints/users/lists/delete.js'; import * as ep___users_lists_list from './endpoints/users/lists/list.js'; @@ -527,6 +540,7 @@ const $i_signinHistory: Provider = { provide: 'ep:i/signin-history', useClass: e const $i_unpin: Provider = { provide: 'ep:i/unpin', useClass: ep___i_unpin.default }; const $i_updateEmail: Provider = { provide: 'ep:i/update-email', useClass: ep___i_updateEmail.default }; const $i_update: Provider = { provide: 'ep:i/update', useClass: ep___i_update.default }; +const $i_userGroupInvites: Provider = { provide: 'ep:i/user-group-invites', useClass: ep___i_userGroupInvites.default }; const $i_webhooks_create: Provider = { provide: 'ep:i/webhooks/create', useClass: ep___i_webhooks_create.default }; const $i_webhooks_list: Provider = { provide: 'ep:i/webhooks/list', useClass: ep___i_webhooks_list.default }; const $i_webhooks_show: Provider = { provide: 'ep:i/webhooks/show', useClass: ep___i_webhooks_show.default }; @@ -609,6 +623,18 @@ const $users_followers: Provider = { provide: 'ep:users/followers', useClass: ep const $users_following: Provider = { provide: 'ep:users/following', useClass: ep___users_following.default }; const $users_gallery_posts: Provider = { provide: 'ep:users/gallery/posts', useClass: ep___users_gallery_posts.default }; const $users_getFrequentlyRepliedUsers: Provider = { provide: 'ep:users/get-frequently-replied-users', useClass: ep___users_getFrequentlyRepliedUsers.default }; +const $users_groups_create: Provider = { provide: 'ep:users/groups/create', useClass: ep___users_groups_create.default }; +const $users_groups_delete: Provider = { provide: 'ep:users/groups/delete', useClass: ep___users_groups_delete.default }; +const $users_groups_invitations_accept: Provider = { provide: 'ep:users/groups/invitations/accept', useClass: ep___users_groups_invitations_accept.default }; +const $users_groups_invitations_reject: Provider = { provide: 'ep:users/groups/invitations/reject', useClass: ep___users_groups_invitations_reject.default }; +const $users_groups_invite: Provider = { provide: 'ep:users/groups/invite', useClass: ep___users_groups_invite.default }; +const $users_groups_joined: Provider = { provide: 'ep:users/groups/joined', useClass: ep___users_groups_joined.default }; +const $users_groups_leave: Provider = { provide: 'ep:users/groups/leave', useClass: ep___users_groups_leave.default }; +const $users_groups_owned: Provider = { provide: 'ep:users/groups/owned', useClass: ep___users_groups_owned.default }; +const $users_groups_pull: Provider = { provide: 'ep:users/groups/pull', useClass: ep___users_groups_pull.default }; +const $users_groups_show: Provider = { provide: 'ep:users/groups/show', useClass: ep___users_groups_show.default }; +const $users_groups_transfer: Provider = { provide: 'ep:users/groups/transfer', useClass: ep___users_groups_transfer.default }; +const $users_groups_update: Provider = { provide: 'ep:users/groups/update', useClass: ep___users_groups_update.default }; const $users_lists_create: Provider = { provide: 'ep:users/lists/create', useClass: ep___users_lists_create.default }; const $users_lists_delete: Provider = { provide: 'ep:users/lists/delete', useClass: ep___users_lists_delete.default }; const $users_lists_list: Provider = { provide: 'ep:users/lists/list', useClass: ep___users_lists_list.default }; @@ -847,6 +873,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_unpin, $i_updateEmail, $i_update, + $i_userGroupInvites, $i_webhooks_create, $i_webhooks_list, $i_webhooks_show, @@ -929,6 +956,18 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $users_following, $users_gallery_posts, $users_getFrequentlyRepliedUsers, + $users_groups_create, + $users_groups_delete, + $users_groups_invitations_accept, + $users_groups_invitations_reject, + $users_groups_invite, + $users_groups_joined, + $users_groups_leave, + $users_groups_owned, + $users_groups_pull, + $users_groups_show, + $users_groups_transfer, + $users_groups_update, $users_lists_create, $users_lists_delete, $users_lists_list, @@ -1161,6 +1200,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_unpin, $i_updateEmail, $i_update, + $i_userGroupInvites, $i_webhooks_create, $i_webhooks_list, $i_webhooks_show, @@ -1241,6 +1281,18 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $users_following, $users_gallery_posts, $users_getFrequentlyRepliedUsers, + $users_groups_create, + $users_groups_delete, + $users_groups_invitations_accept, + $users_groups_invitations_reject, + $users_groups_invite, + $users_groups_joined, + $users_groups_leave, + $users_groups_owned, + $users_groups_pull, + $users_groups_show, + $users_groups_transfer, + $users_groups_update, $users_lists_create, $users_lists_delete, $users_lists_list, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 23aa7fab0f..639cf30195 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -210,6 +210,7 @@ import * as ep___i_signinHistory from './endpoints/i/signin-history.js'; import * as ep___i_unpin from './endpoints/i/unpin.js'; import * as ep___i_updateEmail from './endpoints/i/update-email.js'; import * as ep___i_update from './endpoints/i/update.js'; +import * as ep___i_userGroupInvites from './endpoints/i/user-group-invites.js'; import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js'; import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js'; import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js'; @@ -292,6 +293,18 @@ import * as ep___users_followers from './endpoints/users/followers.js'; import * as ep___users_following from './endpoints/users/following.js'; import * as ep___users_gallery_posts from './endpoints/users/gallery/posts.js'; import * as ep___users_getFrequentlyRepliedUsers from './endpoints/users/get-frequently-replied-users.js'; +import * as ep___users_groups_create from './endpoints/users/groups/create.js'; +import * as ep___users_groups_delete from './endpoints/users/groups/delete.js'; +import * as ep___users_groups_invitations_accept from './endpoints/users/groups/invitations/accept.js'; +import * as ep___users_groups_invitations_reject from './endpoints/users/groups/invitations/reject.js'; +import * as ep___users_groups_invite from './endpoints/users/groups/invite.js'; +import * as ep___users_groups_joined from './endpoints/users/groups/joined.js'; +import * as ep___users_groups_leave from './endpoints/users/groups/leave.js'; +import * as ep___users_groups_owned from './endpoints/users/groups/owned.js'; +import * as ep___users_groups_pull from './endpoints/users/groups/pull.js'; +import * as ep___users_groups_show from './endpoints/users/groups/show.js'; +import * as ep___users_groups_transfer from './endpoints/users/groups/transfer.js'; +import * as ep___users_groups_update from './endpoints/users/groups/update.js'; import * as ep___users_lists_create from './endpoints/users/lists/create.js'; import * as ep___users_lists_delete from './endpoints/users/lists/delete.js'; import * as ep___users_lists_list from './endpoints/users/lists/list.js'; @@ -524,6 +537,7 @@ const eps = [ ['i/unpin', ep___i_unpin], ['i/update-email', ep___i_updateEmail], ['i/update', ep___i_update], + ['i/user-group-invites', ep___i_userGroupInvites], ['i/webhooks/create', ep___i_webhooks_create], ['i/webhooks/list', ep___i_webhooks_list], ['i/webhooks/show', ep___i_webhooks_show], @@ -606,6 +620,18 @@ const eps = [ ['users/following', ep___users_following], ['users/gallery/posts', ep___users_gallery_posts], ['users/get-frequently-replied-users', ep___users_getFrequentlyRepliedUsers], + ['users/groups/create', ep___users_groups_create], + ['users/groups/delete', ep___users_groups_delete], + ['users/groups/invitations/accept', ep___users_groups_invitations_accept], + ['users/groups/invitations/reject', ep___users_groups_invitations_reject], + ['users/groups/invite', ep___users_groups_invite], + ['users/groups/joined', ep___users_groups_joined], + ['users/groups/leave', ep___users_groups_leave], + ['users/groups/owned', ep___users_groups_owned], + ['users/groups/pull', ep___users_groups_pull], + ['users/groups/show', ep___users_groups_show], + ['users/groups/transfer', ep___users_groups_transfer], + ['users/groups/update', ep___users_groups_update], ['users/lists/create', ep___users_lists_create], ['users/lists/delete', ep___users_lists_delete], ['users/lists/list', ep___users_lists_list], diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts index bc5d249ae5..a1553b6a80 100644 --- a/packages/backend/src/server/api/endpoints/antennas/create.ts +++ b/packages/backend/src/server/api/endpoints/antennas/create.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { IdService } from '@/core/IdService.js'; -import type { UserListsRepository, AntennasRepository } from '@/models/index.js'; +import type { UserListsRepository, UserGroupJoiningsRepository, AntennasRepository } from '@/models/index.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { AntennaEntityService } from '@/core/entities/AntennaEntityService.js'; import { DI } from '@/di-symbols.js'; @@ -22,6 +22,12 @@ export const meta = { id: '95063e93-a283-4b8b-9aa5-bcdb8df69a7f', }, + noSuchUserGroup: { + message: 'No such user group.', + code: 'NO_SUCH_USER_GROUP', + id: 'aa3c0b9a-8cae-47c0-92ac-202ce5906682', + }, + tooManyAntennas: { message: 'You cannot create antenna any more.', code: 'TOO_MANY_ANTENNAS', @@ -40,8 +46,9 @@ export const paramDef = { type: 'object', properties: { name: { type: 'string', minLength: 1, maxLength: 100 }, - src: { type: 'string', enum: ['home', 'all', 'users', 'list'] }, + src: { type: 'string', enum: ['home', 'all', 'users', 'list', 'group'] }, userListId: { type: 'string', format: 'misskey:id', nullable: true }, + userGroupId: { type: 'string', format: 'misskey:id', nullable: true }, keywords: { type: 'array', items: { type: 'array', items: { type: 'string', @@ -73,6 +80,9 @@ export default class extends Endpoint { @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + private antennaEntityService: AntennaEntityService, private roleService: RoleService, private idService: IdService, @@ -87,6 +97,7 @@ export default class extends Endpoint { } let userList; + let userGroupJoining; if (ps.src === 'list' && ps.userListId) { userList = await this.userListsRepository.findOneBy({ @@ -97,6 +108,15 @@ export default class extends Endpoint { if (userList == null) { throw new ApiError(meta.errors.noSuchUserList); } + } else if (ps.src === 'group' && ps.userGroupId) { + userGroupJoining = await this.userGroupJoiningsRepository.findOneBy({ + userGroupId: ps.userGroupId, + userId: me.id, + }); + + if (userGroupJoining == null) { + throw new ApiError(meta.errors.noSuchUserGroup); + } } const antenna = await this.antennasRepository.insert({ @@ -106,6 +126,7 @@ export default class extends Endpoint { name: ps.name, src: ps.src, userListId: userList ? userList.id : null, + userGroupJoiningId: userGroupJoining ? userGroupJoining.id : null, keywords: ps.keywords, excludeKeywords: ps.excludeKeywords, users: ps.users, diff --git a/packages/backend/src/server/api/endpoints/antennas/update.ts b/packages/backend/src/server/api/endpoints/antennas/update.ts index 3f85442131..1955eac949 100644 --- a/packages/backend/src/server/api/endpoints/antennas/update.ts +++ b/packages/backend/src/server/api/endpoints/antennas/update.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AntennasRepository, UserListsRepository } from '@/models/index.js'; +import type { AntennasRepository, UserListsRepository, UserGroupJoiningsRepository } from '@/models/index.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { AntennaEntityService } from '@/core/entities/AntennaEntityService.js'; import { DI } from '@/di-symbols.js'; @@ -25,6 +25,12 @@ export const meta = { code: 'NO_SUCH_USER_LIST', id: '1c6b35c9-943e-48c2-81e4-2844989407f7', }, + + noSuchUserGroup: { + message: 'No such user group.', + code: 'NO_SUCH_USER_GROUP', + id: '109ed789-b6eb-456e-b8a9-6059d567d385', + }, }, res: { @@ -39,8 +45,9 @@ export const paramDef = { properties: { antennaId: { type: 'string', format: 'misskey:id' }, name: { type: 'string', minLength: 1, maxLength: 100 }, - src: { type: 'string', enum: ['home', 'all', 'users', 'list'] }, + src: { type: 'string', enum: ['home', 'all', 'users', 'list', 'group'] }, userListId: { type: 'string', format: 'misskey:id', nullable: true }, + userGroupId: { type: 'string', format: 'misskey:id', nullable: true }, keywords: { type: 'array', items: { type: 'array', items: { type: 'string', @@ -71,6 +78,9 @@ export default class extends Endpoint { @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, + + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, private antennaEntityService: AntennaEntityService, private globalEventService: GlobalEventService, @@ -87,6 +97,7 @@ export default class extends Endpoint { } let userList; + let userGroupJoining; if (ps.src === 'list' && ps.userListId) { userList = await this.userListsRepository.findOneBy({ @@ -97,12 +108,22 @@ export default class extends Endpoint { if (userList == null) { throw new ApiError(meta.errors.noSuchUserList); } + } else if (ps.src === 'group' && ps.userGroupId) { + userGroupJoining = await this.userGroupJoiningsRepository.findOneBy({ + userGroupId: ps.userGroupId, + userId: me.id, + }); + + if (userGroupJoining == null) { + throw new ApiError(meta.errors.noSuchUserGroup); + } } await this.antennasRepository.update(antenna.id, { name: ps.name, src: ps.src, userListId: userList ? userList.id : null, + userGroupJoiningId: userGroupJoining ? userGroupJoining.id : null, keywords: ps.keywords, excludeKeywords: ps.excludeKeywords, users: ps.users, diff --git a/packages/backend/src/server/api/endpoints/i/user-group-invites.ts b/packages/backend/src/server/api/endpoints/i/user-group-invites.ts new file mode 100644 index 0000000000..1ad2f7d68f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/user-group-invites.ts @@ -0,0 +1,69 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { UserGroupInvitationsRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { UserGroupInvitationEntityService } from '@/core/entities/UserGroupInvitationEntityService.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['account', 'groups'], + + requireCredential: true, + + kind: 'read:user-groups', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + group: { + type: 'object', + optional: false, nullable: false, + ref: 'UserGroup', + }, + }, + }, + }, +} 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' }, + }, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userGroupInvitationsRepository) + private userGroupInvitationsRepository: UserGroupInvitationsRepository, + + private userGroupInvitationEntityService: UserGroupInvitationEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.userGroupInvitationsRepository.createQueryBuilder('invitation'), ps.sinceId, ps.untilId) + .andWhere('invitation.userId = :meId', { meId: me.id }) + .leftJoinAndSelect('invitation.userGroup', 'user_group'); + + const invitations = await query + .take(ps.limit) + .getMany(); + + return await this.userGroupInvitationEntityService.packMany(invitations); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/groups/create.ts b/packages/backend/src/server/api/endpoints/users/groups/create.ts new file mode 100644 index 0000000000..24dbf5ca3c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/create.ts @@ -0,0 +1,72 @@ +import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; +import type { UserGroupsRepository, UserGroupJoiningsRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import type { UserGroup } from '@/models/entities/UserGroup.js'; +import type { UserGroupJoining } from '@/models/entities/UserGroupJoining.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserGroupEntityService } from '@/core/entities/UserGroupEntityService.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['groups'], + + requireCredential: true, + + kind: 'write:user-groups', + + description: 'Create a new group.', + + limit: { + duration: ms('1hour'), + max: 10, + }, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'UserGroup', + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + name: { type: 'string', minLength: 1, maxLength: 100 }, + }, + required: ['name'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userGroupsRepository) + private userGroupsRepository: UserGroupsRepository, + + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + private userGroupEntityService: UserGroupEntityService, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const userGroup = await this.userGroupsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: me.id, + name: ps.name, + } as UserGroup).then(x => this.userGroupsRepository.findOneByOrFail(x.identifiers[0])); + + // Push the owner + await this.userGroupJoiningsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: me.id, + userGroupId: userGroup.id, + } as UserGroupJoining); + + return await this.userGroupEntityService.pack(userGroup); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/groups/delete.ts b/packages/backend/src/server/api/endpoints/users/groups/delete.ts new file mode 100644 index 0000000000..d238ae9f16 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/delete.ts @@ -0,0 +1,53 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { UserGroupsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['groups'], + + requireCredential: true, + + kind: 'write:user-groups', + + description: 'Delete an existing group.', + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: '63dbd64c-cd77-413f-8e08-61781e210b38', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + groupId: { type: 'string', format: 'misskey:id' }, + }, + required: ['groupId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userGroupsRepository) + private userGroupsRepository: UserGroupsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const userGroup = await this.userGroupsRepository.findOneBy({ + id: ps.groupId, + userId: me.id, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + await this.userGroupsRepository.delete(userGroup.id); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/groups/invitations/accept.ts b/packages/backend/src/server/api/endpoints/users/groups/invitations/accept.ts new file mode 100644 index 0000000000..f154a57f61 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/invitations/accept.ts @@ -0,0 +1,72 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { UserGroupInvitationsRepository, UserGroupJoiningsRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import type { UserGroupJoining } from '@/models/entities/UserGroupJoining.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../../../error.js'; + +export const meta = { + tags: ['groups', 'users'], + + requireCredential: true, + + kind: 'write:user-groups', + + description: 'Join a group the authenticated user has been invited to.', + + errors: { + noSuchInvitation: { + message: 'No such invitation.', + code: 'NO_SUCH_INVITATION', + id: '98c11eca-c890-4f42-9806-c8c8303ebb5e', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + invitationId: { type: 'string', format: 'misskey:id' }, + }, + required: ['invitationId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userGroupInvitationsRepository) + private userGroupInvitationsRepository: UserGroupInvitationsRepository, + + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch the invitation + const invitation = await this.userGroupInvitationsRepository.findOneBy({ + id: ps.invitationId, + }); + + if (invitation == null) { + throw new ApiError(meta.errors.noSuchInvitation); + } + + if (invitation.userId !== me.id) { + throw new ApiError(meta.errors.noSuchInvitation); + } + + // Push the user + await this.userGroupJoiningsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: me.id, + userGroupId: invitation.userGroupId, + } as UserGroupJoining); + + this.userGroupInvitationsRepository.delete(invitation.id); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/groups/invitations/reject.ts b/packages/backend/src/server/api/endpoints/users/groups/invitations/reject.ts new file mode 100644 index 0000000000..1fd3b2f4b3 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/invitations/reject.ts @@ -0,0 +1,57 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { UserGroupInvitationsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../../../error.js'; + +export const meta = { + tags: ['groups', 'users'], + + requireCredential: true, + + kind: 'write:user-groups', + + description: 'Delete an existing group invitation for the authenticated user without joining the group.', + + errors: { + noSuchInvitation: { + message: 'No such invitation.', + code: 'NO_SUCH_INVITATION', + id: 'ad7471d4-2cd9-44b4-ac68-e7136b4ce656', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + invitationId: { type: 'string', format: 'misskey:id' }, + }, + required: ['invitationId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userGroupInvitationsRepository) + private userGroupInvitationsRepository: UserGroupInvitationsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch the invitation + const invitation = await this.userGroupInvitationsRepository.findOneBy({ + id: ps.invitationId, + }); + + if (invitation == null) { + throw new ApiError(meta.errors.noSuchInvitation); + } + + if (invitation.userId !== me.id) { + throw new ApiError(meta.errors.noSuchInvitation); + } + + await this.userGroupInvitationsRepository.delete(invitation.id); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/groups/invite.ts b/packages/backend/src/server/api/endpoints/users/groups/invite.ts new file mode 100644 index 0000000000..2e040c0601 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/invite.ts @@ -0,0 +1,122 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { UserGroupsRepository, UserGroupJoiningsRepository, UserGroupInvitationsRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import type { UserGroupInvitation } from '@/models/entities/UserGroupInvitation.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { CreateNotificationService } from '@/core/CreateNotificationService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['groups', 'users'], + + requireCredential: true, + + kind: 'write:user-groups', + + description: 'Invite a user to an existing group.', + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: '583f8bc0-8eee-4b78-9299-1e14fc91e409', + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'da52de61-002c-475b-90e1-ba64f9cf13a8', + }, + + alreadyAdded: { + message: 'That user has already been added to that group.', + code: 'ALREADY_ADDED', + id: '7e35c6a0-39b2-4488-aea6-6ee20bd5da2c', + }, + + alreadyInvited: { + message: 'That user has already been invited to that group.', + code: 'ALREADY_INVITED', + id: 'ee0f58b4-b529-4d13-b761-b9a3e69f97e6', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + groupId: { type: 'string', format: 'misskey:id' }, + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['groupId', 'userId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userGroupsRepository) + private userGroupsRepository: UserGroupsRepository, + + @Inject(DI.userGroupInvitationsRepository) + private userGroupInvitationsRepository: UserGroupInvitationsRepository, + + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + private idService: IdService, + private getterService: GetterService, + private createNotificationService: CreateNotificationService, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch the group + const userGroup = await this.userGroupsRepository.findOneBy({ + id: ps.groupId, + userId: me.id, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // Fetch the user + const user = 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 joining = await this.userGroupJoiningsRepository.findOneBy({ + userGroupId: userGroup.id, + userId: user.id, + }); + + if (joining) { + throw new ApiError(meta.errors.alreadyAdded); + } + + const existInvitation = await this.userGroupInvitationsRepository.findOneBy({ + userGroupId: userGroup.id, + userId: user.id, + }); + + if (existInvitation) { + throw new ApiError(meta.errors.alreadyInvited); + } + + const invitation = await this.userGroupInvitationsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: user.id, + userGroupId: userGroup.id, + } as UserGroupInvitation).then(x => this.userGroupInvitationsRepository.findOneByOrFail(x.identifiers[0])); + + // 通知を作成 + this.createNotificationService.createNotification(user.id, 'groupInvited', { + notifierId: me.id, + userGroupInvitationId: invitation.id, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/groups/joined.ts b/packages/backend/src/server/api/endpoints/users/groups/joined.ts new file mode 100644 index 0000000000..8daee3a6f5 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/joined.ts @@ -0,0 +1,61 @@ +import { Not, In } from 'typeorm'; +import { Inject, Injectable } from '@nestjs/common'; +import type { UserGroupsRepository, UserGroupJoiningsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserGroupEntityService } from '@/core/entities/UserGroupEntityService.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['groups', 'account'], + + requireCredential: true, + + kind: 'read:user-groups', + + description: 'List the groups that the authenticated user is a member of.', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'UserGroup', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userGroupsRepository) + private userGroupsRepository: UserGroupsRepository, + + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + private userGroupEntityService: UserGroupEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const ownedGroups = await this.userGroupsRepository.findBy({ + userId: me.id, + }); + + const joinings = await this.userGroupJoiningsRepository.findBy({ + userId: me.id, + ...(ownedGroups.length > 0 ? { + userGroupId: Not(In(ownedGroups.map(x => x.id))), + } : {}), + }); + + return await Promise.all(joinings.map(x => this.userGroupEntityService.pack(x.userGroupId))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/groups/leave.ts b/packages/backend/src/server/api/endpoints/users/groups/leave.ts new file mode 100644 index 0000000000..846f80e64d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/leave.ts @@ -0,0 +1,66 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { UserGroupsRepository, UserGroupJoiningsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['groups', 'users'], + + requireCredential: true, + + kind: 'write:user-groups', + + description: 'Leave a group. The owner of a group can not leave. They must transfer ownership or delete the group instead.', + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: '62780270-1f67-5dc0-daca-3eb510612e31', + }, + + youAreOwner: { + message: 'Your are the owner.', + code: 'YOU_ARE_OWNER', + id: 'b6d6e0c2-ef8a-9bb8-653d-79f4a3107c69', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + groupId: { type: 'string', format: 'misskey:id' }, + }, + required: ['groupId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userGroupsRepository) + private userGroupsRepository: UserGroupsRepository, + + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch the group + const userGroup = await this.userGroupsRepository.findOneBy({ + id: ps.groupId, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + if (me.id === userGroup.userId) { + throw new ApiError(meta.errors.youAreOwner); + } + + await this.userGroupJoiningsRepository.delete({ userGroupId: userGroup.id, userId: me.id }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/groups/owned.ts b/packages/backend/src/server/api/endpoints/users/groups/owned.ts new file mode 100644 index 0000000000..0bc6e8b3fc --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/owned.ts @@ -0,0 +1,50 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { UserGroupsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserGroupEntityService } from '@/core/entities/UserGroupEntityService.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['groups', 'account'], + + requireCredential: true, + + kind: 'read:user-groups', + + description: 'List the groups that the authenticated user is the owner of.', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'UserGroup', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userGroupsRepository) + private userGroupsRepository: UserGroupsRepository, + + private userGroupEntityService: UserGroupEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const userGroups = await this.userGroupsRepository.findBy({ + userId: me.id, + }); + + return await Promise.all(userGroups.map(x => this.userGroupEntityService.pack(x))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/groups/pull.ts b/packages/backend/src/server/api/endpoints/users/groups/pull.ts new file mode 100644 index 0000000000..409006b0b0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/pull.ts @@ -0,0 +1,84 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { UserGroupsRepository, UserGroupJoiningsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['groups', 'users'], + + requireCredential: true, + + kind: 'write:user-groups', + + description: 'Removes a specified user from a group. The owner can not be removed.', + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: '4662487c-05b1-4b78-86e5-fd46998aba74', + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '0b5cc374-3681-41da-861e-8bc1146f7a55', + }, + + isOwner: { + message: 'The user is the owner.', + code: 'IS_OWNER', + id: '1546eed5-4414-4dea-81c1-b0aec4f6d2af', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + groupId: { type: 'string', format: 'misskey:id' }, + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['groupId', 'userId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userGroupsRepository) + private userGroupsRepository: UserGroupsRepository, + + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + private getterService: GetterService, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch the group + const userGroup = await this.userGroupsRepository.findOneBy({ + id: ps.groupId, + userId: me.id, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // Fetch the user + const user = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + if (user.id === userGroup.userId) { + throw new ApiError(meta.errors.isOwner); + } + + // Pull the user + await this.userGroupJoiningsRepository.delete({ userGroupId: userGroup.id, userId: user.id }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/groups/show.ts b/packages/backend/src/server/api/endpoints/users/groups/show.ts new file mode 100644 index 0000000000..2b0f403f33 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/show.ts @@ -0,0 +1,74 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { UserGroupsRepository, UserGroupJoiningsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserGroupEntityService } from '@/core/entities/UserGroupEntityService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['groups', 'account'], + + requireCredential: true, + + kind: 'read:user-groups', + + description: 'Show the properties of a group.', + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'UserGroup', + }, + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: 'ea04751e-9b7e-487b-a509-330fb6bd6b9b', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + groupId: { type: 'string', format: 'misskey:id' }, + }, + required: ['groupId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userGroupsRepository) + private userGroupsRepository: UserGroupsRepository, + + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + private userGroupEntityService: UserGroupEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch the group + const userGroup = await this.userGroupsRepository.findOneBy({ + id: ps.groupId, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + const joining = await this.userGroupJoiningsRepository.findOneBy({ + userId: me.id, + userGroupId: userGroup.id, + }); + + if (joining == null && userGroup.userId !== me.id) { + throw new ApiError(meta.errors.noSuchGroup); + } + + return await this.userGroupEntityService.pack(userGroup); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/groups/transfer.ts b/packages/backend/src/server/api/endpoints/users/groups/transfer.ts new file mode 100644 index 0000000000..3130d98ed1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/transfer.ts @@ -0,0 +1,100 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { UserGroupsRepository, UserGroupJoiningsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserGroupEntityService } from '@/core/entities/UserGroupEntityService.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['groups', 'users'], + + requireCredential: true, + + kind: 'write:user-groups', + + description: 'Transfer ownership of a group from the authenticated user to another user.', + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'UserGroup', + }, + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: '8e31d36b-2f88-4ccd-a438-e2d78a9162db', + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '711f7ebb-bbb9-4dfa-b540-b27809fed5e9', + }, + + noSuchGroupMember: { + message: 'No such group member.', + code: 'NO_SUCH_GROUP_MEMBER', + id: 'd31bebee-196d-42c2-9a3e-9474d4be6cc4', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + groupId: { type: 'string', format: 'misskey:id' }, + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['groupId', 'userId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userGroupsRepository) + private userGroupsRepository: UserGroupsRepository, + + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + private userGroupEntityService: UserGroupEntityService, + private getterService: GetterService, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch the group + const userGroup = await this.userGroupsRepository.findOneBy({ + id: ps.groupId, + userId: me.id, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // Fetch the user + const user = 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 joining = await this.userGroupJoiningsRepository.findOneBy({ + userGroupId: userGroup.id, + userId: user.id, + }); + + if (joining == null) { + throw new ApiError(meta.errors.noSuchGroupMember); + } + + await this.userGroupsRepository.update(userGroup.id, { + userId: ps.userId, + }); + + return await this.userGroupEntityService.pack(userGroup.id); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/groups/update.ts b/packages/backend/src/server/api/endpoints/users/groups/update.ts new file mode 100644 index 0000000000..5af849de14 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/update.ts @@ -0,0 +1,68 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { UserGroupsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserGroupEntityService } from '@/core/entities/UserGroupEntityService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['groups'], + + requireCredential: true, + + kind: 'write:user-groups', + + description: 'Update the properties of a group.', + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'UserGroup', + }, + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: '9081cda3-7a9e-4fac-a6ce-908d70f282f6', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + groupId: { type: 'string', format: 'misskey:id' }, + name: { type: 'string', minLength: 1, maxLength: 100 }, + }, + required: ['groupId', 'name'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userGroupsRepository) + private userGroupsRepository: UserGroupsRepository, + + private userGroupEntityService: UserGroupEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + // Fetch the group + const userGroup = await this.userGroupsRepository.findOneBy({ + id: ps.groupId, + userId: me.id, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + await this.userGroupsRepository.update(userGroup.id, { + name: ps.name, + }); + + return await this.userGroupEntityService.pack(userGroup.id); + }); + } +} diff --git a/packages/backend/src/server/api/stream/index.ts b/packages/backend/src/server/api/stream/index.ts index d3056aca57..fea06c0315 100644 --- a/packages/backend/src/server/api/stream/index.ts +++ b/packages/backend/src/server/api/stream/index.ts @@ -3,6 +3,7 @@ import type { Channel as ChannelModel } from '@/models/entities/Channel.js'; import type { FollowingsRepository, MutingsRepository, UserProfilesRepository, ChannelFollowingsRepository, BlockingsRepository } from '@/models/index.js'; import type { AccessToken } from '@/models/entities/AccessToken.js'; import type { UserProfile } from '@/models/entities/UserProfile.js'; +import type { UserGroup } from '@/models/entities/UserGroup.js'; import type { Packed } from '@/misc/schema.js'; import type { GlobalEventService } from '@/core/GlobalEventService.js'; import type { NoteReadService } from '@/core/NoteReadService.js'; diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts index ac62e714ae..3d8cfea9d3 100644 --- a/packages/backend/src/server/api/stream/types.ts +++ b/packages/backend/src/server/api/stream/types.ts @@ -6,6 +6,7 @@ import type { Antenna } from '@/models/entities/Antenna.js'; import type { DriveFile } from '@/models/entities/DriveFile.js'; import type { DriveFolder } from '@/models/entities/DriveFolder.js'; import type { UserList } from '@/models/entities/UserList.js'; +import type { UserGroup } from '@/models/entities/UserGroup.js'; import type { AbuseUserReport } from '@/models/entities/AbuseUserReport.js'; import type { Signin } from '@/models/entities/Signin.js'; import type { Page } from '@/models/entities/Page.js'; diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 8ac65a021c..7e9e193362 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -1,4 +1,4 @@ -export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'achievementEarned', 'app'] as const; +export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'achievementEarned', 'app'] as const; export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const; diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index 38bf416ea8..0d42e8ffbf 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -9,6 +9,7 @@ + @@ -73,6 +74,12 @@ | + @@ -138,6 +145,7 @@ onUnmounted(() => { }); const followRequestDone = ref(false); +const groupInviteDone = ref(false); const acceptFollowRequest = () => { followRequestDone.value = true; @@ -149,6 +157,16 @@ const rejectFollowRequest = () => { os.api('following/requests/reject', { userId: props.notification.user.id }); }; +const acceptGroupInvitation = () => { + groupInviteDone.value = true; + os.apiWithDialog('users/groups/invitations/accept', { invitationId: props.notification.invitation.id }); +}; + +const rejectGroupInvitation = () => { + groupInviteDone.value = true; + os.api('users/groups/invitations/reject', { invitationId: props.notification.invitation.id }); +}; + useTooltip(reactionRef, (showing) => { os.popup(XReactionTooltip, { showing, @@ -206,7 +224,7 @@ useTooltip(reactionRef, (showing) => { } } -.t_follow, .t_followRequestAccepted, .t_receiveFollowRequest { +.t_follow, .t_followRequestAccepted, .t_receiveFollowRequest, .t_groupInvited { padding: 3px; background: #36aed2; pointer-events: none; diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts index aece325148..687be345da 100644 --- a/packages/frontend/src/navbar.ts +++ b/packages/frontend/src/navbar.ts @@ -50,6 +50,14 @@ export const navbarItemDef = reactive({ show: computed(() => $i != null), to: '/my/lists', }, + /* + groups: { + title: i18n.ts.groups, + icon: 'ti ti-users', + show: computed(() => $i != null), + to: '/my/groups', + }, + */ antennas: { title: i18n.ts.antennas, icon: 'ti ti-antenna', diff --git a/packages/frontend/src/pages/my-antennas/create.vue b/packages/frontend/src/pages/my-antennas/create.vue index f3eba88373..005b036696 100644 --- a/packages/frontend/src/pages/my-antennas/create.vue +++ b/packages/frontend/src/pages/my-antennas/create.vue @@ -17,6 +17,7 @@ let draft = $ref({ name: '', src: 'all', userListId: null, + userGroupId: null, users: [], keywords: [], excludeKeywords: [], diff --git a/packages/frontend/src/pages/my-antennas/editor.vue b/packages/frontend/src/pages/my-antennas/editor.vue index 26b7bcc71b..5c1eb66947 100644 --- a/packages/frontend/src/pages/my-antennas/editor.vue +++ b/packages/frontend/src/pages/my-antennas/editor.vue @@ -11,11 +11,16 @@ + + + + + @@ -65,6 +70,7 @@ const emit = defineEmits<{ let name: string = $ref(props.antenna.name); let src: string = $ref(props.antenna.src); let userListId: any = $ref(props.antenna.userListId); +let userGroupId: any = $ref(props.antenna.userGroupId); let users: string = $ref(props.antenna.users.join('\n')); let keywords: string = $ref(props.antenna.keywords.map(x => x.join(' ')).join('\n')); let excludeKeywords: string = $ref(props.antenna.excludeKeywords.map(x => x.join(' ')).join('\n')); @@ -73,11 +79,19 @@ let withReplies: boolean = $ref(props.antenna.withReplies); let withFile: boolean = $ref(props.antenna.withFile); let notify: boolean = $ref(props.antenna.notify); let userLists: any = $ref(null); +let userGroups: any = $ref(null); watch(() => src, async () => { if (src === 'list' && userLists === null) { userLists = await os.api('users/lists/list'); } + + if (src === 'group' && userGroups === null) { + const groups1 = await os.api('users/groups/owned'); + const groups2 = await os.api('users/groups/joined'); + + userGroups = [...groups1, ...groups2]; + } }); async function saveAntenna() { @@ -85,6 +99,7 @@ async function saveAntenna() { name, src, userListId, + userGroupId, withReplies, withFile, notify, diff --git a/packages/frontend/src/pages/settings/email.vue b/packages/frontend/src/pages/settings/email.vue index 1734dcfe42..57b07b1cc1 100644 --- a/packages/frontend/src/pages/settings/email.vue +++ b/packages/frontend/src/pages/settings/email.vue @@ -34,6 +34,9 @@ {{ i18n.ts._notification._types.receiveFollowRequest }} + + {{ i18n.ts._notification._types.groupInvited }} + @@ -75,6 +78,7 @@ const emailNotification_reply = ref($i!.emailNotificationTypes.includes('reply') const emailNotification_quote = ref($i!.emailNotificationTypes.includes('quote')); const emailNotification_follow = ref($i!.emailNotificationTypes.includes('follow')); const emailNotification_receiveFollowRequest = ref($i!.emailNotificationTypes.includes('receiveFollowRequest')); +const emailNotification_groupInvited = ref($i!.emailNotificationTypes.includes('groupInvited')); const saveNotificationSettings = () => { os.api('i/update', { @@ -84,11 +88,12 @@ const saveNotificationSettings = () => { ...[emailNotification_quote.value ? 'quote' : null], ...[emailNotification_follow.value ? 'follow' : null], ...[emailNotification_receiveFollowRequest.value ? 'receiveFollowRequest' : null], + ...[emailNotification_groupInvited.value ? 'groupInvited' : null], ].filter(x => x != null), }); }; -watch([emailNotification_mention, emailNotification_reply, emailNotification_quote, emailNotification_follow, emailNotification_receiveFollowRequest], () => { +watch([emailNotification_mention, emailNotification_reply, emailNotification_quote, emailNotification_follow, emailNotification_receiveFollowRequest, emailNotification_groupInvited], () => { saveNotificationSettings(); }); diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts index cb4c2e7f70..941d9a0db9 100644 --- a/packages/frontend/src/scripts/get-user-menu.ts +++ b/packages/frontend/src/scripts/get-user-menu.ts @@ -35,6 +35,28 @@ export function getUserMenu(user, router: Router = mainRouter) { }); } + async function inviteGroup() { + const groups = await os.api('users/groups/owned'); + if (groups.length === 0) { + os.alert({ + type: 'error', + text: i18n.ts.youHaveNoGroups, + }); + return; + } + const { canceled, result: groupId } = await os.select({ + title: i18n.ts.group, + items: groups.map(group => ({ + value: group.id, text: group.name, + })), + }); + if (canceled) return; + os.apiWithDialog('users/groups/invite', { + groupId: groupId, + userId: user.id, + }); + } + async function toggleMute() { if (user.isMuted) { os.apiWithDialog('mute/delete', { @@ -134,11 +156,20 @@ export function getUserMenu(user, router: Router = mainRouter) { action: () => { os.post({ specified: user }); }, - }, null, { + }, meId !== user.id ? { + type: 'link', + icon: 'ti ti-messages', + text: i18n.ts.startMessaging, + to: '/my/messaging/' + Acct.toString(user), + } : undefined, null, { icon: 'ti ti-list', text: i18n.ts.addToList, action: pushList, - }] as any; + }, meId !== user.id ? { + icon: 'ti ti-users', + text: i18n.ts.inviteToGroup, + action: inviteGroup, + } : undefined] as any; if ($i && meId !== user.id) { menu = menu.concat([null, { diff --git a/packages/sw/src/scripts/create-notification.ts b/packages/sw/src/scripts/create-notification.ts index c121b30bef..1ec4491273 100644 --- a/packages/sw/src/scripts/create-notification.ts +++ b/packages/sw/src/scripts/create-notification.ts @@ -209,6 +209,23 @@ async function composeNotification(data: pushNotificationDataMap[keyof pushNotif data, }]; + case 'groupInvited': + return [t('_notification.youWereInvitedToGroup', { userName: getUserName(data.body.user) }), { + body: data.body.invitation.group.name, + badge: iconUrl('users'), + data, + actions: [ + { + action: 'accept', + title: t('accept'), + }, + { + action: 'reject', + title: t('reject'), + }, + ], + }]; + case 'app': return [data.body.header ?? data.body.body, { body: data.body.header ? data.body.body : '', diff --git a/packages/sw/src/scripts/operations.ts b/packages/sw/src/scripts/operations.ts index 4d693223b2..719e3ea9fa 100644 --- a/packages/sw/src/scripts/operations.ts +++ b/packages/sw/src/scripts/operations.ts @@ -32,10 +32,18 @@ export function openAntenna(antennaId: string, loginId: string) { return openClient('push', `/timeline/antenna/${antennaId}`, loginId, { antennaId }); } +export async function openChat(body: any, loginId: string) { + if (body.groupId === null) { + return openClient('push', `/my/messaging/${getAcct(body.user)}`, loginId, { body }); + } else { + return openClient('push', `/my/messaging/group/${body.groupId}`, loginId, { body }); + } +} + // post-formのオプションから投稿フォームを開く export async function openPost(options: any, loginId: string) { // クエリを作成しておく - let url = '/share?'; + let url = `/share?`; if (options.initialText) url += `text=${options.initialText}&`; if (options.reply) url += `replyId=${options.reply.id}&`; if (options.renote) url += `renoteId=${options.renote.id}&`; @@ -56,7 +64,7 @@ export async function openClient(order: swMessageOrderType, url: string, loginId export async function findClient() { const clients = await self.clients.matchAll({ - type: 'window', + type: 'window' }); for (const c of clients) { if (c.url.indexOf('?zen') < 0) return c; diff --git a/packages/sw/src/sw.ts b/packages/sw/src/sw.ts index c392d03232..f56208d6d6 100644 --- a/packages/sw/src/sw.ts +++ b/packages/sw/src/sw.ts @@ -118,6 +118,9 @@ globalThis.addEventListener('notificationclick', (ev: ServiceWorkerGlobalScopeEv case 'receiveFollowRequest': await swos.api('following/requests/accept', loginId, { userId: data.body.userId }); break; + case 'groupInvited': + await swos.api('users/groups/invitations/accept', loginId, { invitationId: data.body.invitation.id }); + break; } break; case 'reject': @@ -125,6 +128,9 @@ globalThis.addEventListener('notificationclick', (ev: ServiceWorkerGlobalScopeEv case 'receiveFollowRequest': await swos.api('following/requests/reject', loginId, { userId: data.body.userId }); break; + case 'groupInvited': + await swos.api('users/groups/invitations/reject', loginId, { invitationId: data.body.invitation.id }); + break; } break; case 'showFollowRequests': @@ -135,6 +141,9 @@ globalThis.addEventListener('notificationclick', (ev: ServiceWorkerGlobalScopeEv case 'receiveFollowRequest': client = await swos.openClient('push', '/my/follow-requests', loginId); break; + case 'groupInvited': + client = await swos.openClient('push', '/my/groups', loginId); + break; case 'reaction': client = await swos.openNote(data.body.note.id, loginId); break;