diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 092a4aed32..83186dc729 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -993,6 +993,7 @@ noteIdOrUrl: "ノートIDまたはURL" accountMigration: "アカウントの引っ越し" accountMoved: "このユーザーは新しいアカウントに引っ越しました:" forceShowAds: "常に広告を表示する" +addMemo: "メモを追加" _accountMigration: moveTo: "このアカウントを新しいアカウントに引っ越す" diff --git a/packages/backend/migration/1680702787050-UserMemo.js b/packages/backend/migration/1680702787050-UserMemo.js new file mode 100644 index 0000000000..7446bf8da5 --- /dev/null +++ b/packages/backend/migration/1680702787050-UserMemo.js @@ -0,0 +1,18 @@ +export class UserMemo1680702787050 { + name = 'UserMemo1680702787050' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "user_memo" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "targetUserId" character varying(32) NOT NULL, "memo" character varying(2048) NOT NULL, CONSTRAINT "PK_e9aaa58f7d3699a84d79078f4d9" PRIMARY KEY ("id")); COMMENT ON COLUMN "user_memo"."userId" IS 'The ID of author.'; COMMENT ON COLUMN "user_memo"."targetUserId" IS 'The ID of target user.'; COMMENT ON COLUMN "user_memo"."memo" IS 'Memo.'`); + await queryRunner.query(`CREATE INDEX "IDX_650b49c5639b5840ee6a2b8f83" ON "user_memo" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_66ac4a82894297fd09ba61f3d3" ON "user_memo" ("targetUserId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_faef300913c738265638ba3ebc" ON "user_memo" ("userId", "targetUserId") `); + await queryRunner.query(`ALTER TABLE "user_memo" ADD CONSTRAINT "FK_650b49c5639b5840ee6a2b8f83e" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_memo" ADD CONSTRAINT "FK_66ac4a82894297fd09ba61f3d35" FOREIGN KEY ("targetUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_memo" DROP CONSTRAINT "FK_66ac4a82894297fd09ba61f3d35"`); + await queryRunner.query(`ALTER TABLE "user_memo" DROP CONSTRAINT "FK_650b49c5639b5840ee6a2b8f83e"`); + await queryRunner.query(`DROP TABLE "user_memo"`); + } +} diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index e02f7535d4..3f8254bb6c 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -12,7 +12,7 @@ import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.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, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, PagesRepository, UserProfile, RenoteMutingsRepository } from '@/models/index.js'; +import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, PagesRepository, UserProfile, RenoteMutingsRepository, UserMemoRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; @@ -113,6 +113,9 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.pagesRepository) private pagesRepository: PagesRepository, + + @Inject(DI.userMemoRepository) + private userMemoRepository: UserMemoRepository, //private noteEntityService: NoteEntityService, //private driveFileEntityService: DriveFileEntityService, @@ -337,6 +340,11 @@ export class UserEntityService implements OnModuleInit { const falsy = opts.detail ? false : undefined; + const memo = meId == null ? null : await this.userMemoRepository.findOneBy({ + userId: meId, + targetUserId: user.id, + }).then(row => row?.memo ?? null); + const packed = { id: user.id, name: user.name, @@ -476,6 +484,10 @@ export class UserEntityService implements OnModuleInit { isMuted: relation.isMuted, isRenoteMuted: relation.isRenoteMuted, } : {}), + + ...(memo ? { + memo, + } : {}), } as Promiseable> as Promiseable>; return await awaitAll(packed); diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index d4b1fb31b1..b93ba899fb 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -70,5 +70,6 @@ export const DI = { roleAssignmentsRepository: Symbol('roleAssignmentsRepository'), flashsRepository: Symbol('flashsRepository'), flashLikesRepository: Symbol('flashLikesRepository'), + userMemoRepository: Symbol('userMemoRepository'), //#endregion }; diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 7be7b81904..a415e37904 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite } from './index.js'; +import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite, UserMemo } from './index.js'; import type { DataSource } from 'typeorm'; import type { Provider } from '@nestjs/common'; @@ -388,6 +388,12 @@ const $roleAssignmentsRepository: Provider = { inject: [DI.db], }; +const $userMemoRepository: Provider = { + provide: DI.userMemoRepository, + useFactory: (db: DataSource) => db.getRepository(UserMemo), + inject: [DI.db], +}; + @Module({ imports: [ ], @@ -456,6 +462,7 @@ const $roleAssignmentsRepository: Provider = { $roleAssignmentsRepository, $flashsRepository, $flashLikesRepository, + $userMemoRepository, ], exports: [ $usersRepository, @@ -522,6 +529,7 @@ const $roleAssignmentsRepository: Provider = { $roleAssignmentsRepository, $flashsRepository, $flashLikesRepository, + $userMemoRepository, ], }) export class RepositoryModule {} diff --git a/packages/backend/src/models/entities/UserMemo.ts b/packages/backend/src/models/entities/UserMemo.ts new file mode 100644 index 0000000000..7dc34b4346 --- /dev/null +++ b/packages/backend/src/models/entities/UserMemo.ts @@ -0,0 +1,42 @@ +import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; +import { id } from '../id.js'; +import { User } from './User.js'; + +@Entity() +@Index(['userId', 'targetUserId'], { unique: true }) +export class UserMemo { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column({ + ...id(), + comment: 'The ID of author.', + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), + comment: 'The ID of target user.', + }) + public targetUserId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public targetUser: User | null; + + @Column('varchar', { + length: 2048, + comment: 'Memo.', + }) + public memo: string; +} diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index 48d6e15f2a..b8ba28db9b 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -55,6 +55,7 @@ import { UserPending } from '@/models/entities/UserPending.js'; import { UserProfile } from '@/models/entities/UserProfile.js'; import { UserPublickey } from '@/models/entities/UserPublickey.js'; import { UserSecurityKey } from '@/models/entities/UserSecurityKey.js'; +import { UserMemo } from '@/models/entities/UserMemo.js'; import { Webhook } from '@/models/entities/Webhook.js'; import { Channel } from '@/models/entities/Channel.js'; import { RetentionAggregation } from '@/models/entities/RetentionAggregation.js'; @@ -129,6 +130,7 @@ export { RoleAssignment, Flash, FlashLike, + UserMemo, }; export type AbuseUserReportsRepository = Repository; @@ -195,3 +197,4 @@ export type RolesRepository = Repository; export type RoleAssignmentsRepository = Repository; export type FlashsRepository = Repository; export type FlashLikesRepository = Repository; +export type UserMemoRepository = Repository; diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 7d40979e3d..836368886e 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -250,6 +250,10 @@ export const packedUserDetailedNotMeOnlySchema = { type: 'boolean', nullable: false, optional: true, }, + memo: { + type: 'string', + nullable: false, optional: true, + }, //#endregion }, } as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index bb21ed827e..f3d404e6c9 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -70,6 +70,7 @@ import { Role } from '@/models/entities/Role.js'; import { RoleAssignment } from '@/models/entities/RoleAssignment.js'; import { Flash } from '@/models/entities/Flash.js'; import { FlashLike } from '@/models/entities/FlashLike.js'; +import { UserMemo } from '@/models/entities/UserMemo.js'; import { Config } from '@/config.js'; import MisskeyLogger from '@/logger.js'; @@ -183,6 +184,7 @@ export const entities = [ RoleAssignment, Flash, FlashLike, + UserMemo, ...charts, ]; diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 689f90287e..737bf0c84c 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -330,6 +330,7 @@ import * as ep___users_search from './endpoints/users/search.js'; import * as ep___users_show from './endpoints/users/show.js'; import * as ep___users_stats from './endpoints/users/stats.js'; import * as ep___users_achievements from './endpoints/users/achievements.js'; +import * as ep___users_updateMemo from './endpoints/users/update-memo.js'; import * as ep___fetchRss from './endpoints/fetch-rss.js'; import * as ep___retention from './endpoints/retention.js'; import { GetterService } from './GetterService.js'; @@ -665,6 +666,7 @@ const $users_search: Provider = { provide: 'ep:users/search', useClass: ep___use const $users_show: Provider = { provide: 'ep:users/show', useClass: ep___users_show.default }; const $users_stats: Provider = { provide: 'ep:users/stats', useClass: ep___users_stats.default }; const $users_achievements: Provider = { provide: 'ep:users/achievements', useClass: ep___users_achievements.default }; +const $users_updateMemo: Provider = { provide: 'ep:users/update-memo', useClass: ep___users_updateMemo.default }; const $fetchRss: Provider = { provide: 'ep:fetch-rss', useClass: ep___fetchRss.default }; const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention.default }; @@ -1004,6 +1006,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $users_show, $users_stats, $users_achievements, + $users_updateMemo, $fetchRss, $retention, ], @@ -1335,6 +1338,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $users_show, $users_stats, $users_achievements, + $users_updateMemo, $fetchRss, $retention, ], diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index d0fe6a57c1..dc82c04e4e 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -330,6 +330,7 @@ import * as ep___users_search from './endpoints/users/search.js'; import * as ep___users_show from './endpoints/users/show.js'; import * as ep___users_stats from './endpoints/users/stats.js'; import * as ep___users_achievements from './endpoints/users/achievements.js'; +import * as ep___users_updateMemo from './endpoints/users/update-memo.js'; import * as ep___fetchRss from './endpoints/fetch-rss.js'; import * as ep___retention from './endpoints/retention.js'; @@ -663,6 +664,7 @@ const eps = [ ['users/show', ep___users_show], ['users/stats', ep___users_stats], ['users/achievements', ep___users_achievements], + ['users/update-memo', ep___users_updateMemo], ['fetch-rss', ep___fetchRss], ['retention', ep___retention], ]; diff --git a/packages/backend/src/server/api/endpoints/users/update-memo.ts b/packages/backend/src/server/api/endpoints/users/update-memo.ts new file mode 100644 index 0000000000..300435b9ff --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/update-memo.ts @@ -0,0 +1,85 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { IdService } from '@/core/IdService.js'; +import type { UserMemoRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['account'], + + requireCredential: true, + + kind: 'write:account', + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '6fef56f3-e765-4957-88e5-c6f65329b8a5', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + memo: { + type: 'string', + nullable: true, + description: 'A personal memo for the target user. If null or empty, delete the memo.', + }, + }, + required: ['userId', 'memo'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userMemoRepository) + private userMemoRepository: UserMemoRepository, + private getterService: GetterService, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + // Get target + const target = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + // 引数がnullか空文字であれば、パーソナルメモを削除する + if (ps.memo === '' || ps.memo == null) { + await this.userMemoRepository.delete({ + userId: me.id, + targetUserId: target.id, + }); + return; + } + + // 以前に作成されたパーソナルメモがあるかどうか確認 + const previousMemo = await this.userMemoRepository.findOneBy({ + userId: me.id, + targetUserId: target.id, + }); + + if (!previousMemo) { + await this.userMemoRepository.insert({ + id: this.idService.genId(), + userId: me.id, + targetUserId: target.id, + memo: ps.memo, + }); + } else { + await this.userMemoRepository.update(previousMemo.id, { + userId: me.id, + targetUserId: target.id, + memo: ps.memo, + }); + } + }); + } +} diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts index afb72c84d4..07a4ceb91f 100644 --- a/packages/backend/test/e2e/endpoints.ts +++ b/packages/backend/test/e2e/endpoints.ts @@ -849,4 +849,84 @@ describe('Endpoints', () => { assert.strictEqual(res.body.error.code, 'URL_PREVIEW_FAILED'); }); }); + + describe('パーソナルメモ機能のテスト', () => { + test('他者に関するメモを更新できる', async () => { + const memo = '10月まで低浮上とのこと。'; + + const res1 = await api('/users/update-memo', { + memo, + userId: bob.id, + }, alice); + + const res2 = await api('/users/show', { + userId: bob.id, + }, alice); + assert.strictEqual(res1.status, 204); + assert.strictEqual(res2.body?.memo, memo); + }); + + test('自分に関するメモを更新できる', async () => { + const memo = 'チケットを月末までに買う。'; + + const res1 = await api('/users/update-memo', { + memo, + userId: alice.id, + }, alice); + + const res2 = await api('/users/show', { + userId: alice.id, + }, alice); + assert.strictEqual(res1.status, 204); + assert.strictEqual(res2.body?.memo, memo); + }); + + test('メモを削除できる', async () => { + const memo = '10月まで低浮上とのこと。'; + + await api('/users/update-memo', { + memo, + userId: bob.id, + }, alice); + + await api('/users/update-memo', { + memo: '', + userId: bob.id, + }, alice); + + const res = await api('/users/show', { + userId: bob.id, + }, alice); + + assert.strictEqual('memo' in res.body, false); + }); + + test('メモは個人ごとに独立して保存される', async () => { + const memoAliceToBob = '10月まで低浮上とのこと。'; + const memoCarolToBob = '例の件について今度問いただす。'; + + await Promise.all([ + api('/users/update-memo', { + memo: memoAliceToBob, + userId: bob.id, + }, alice), + api('/users/update-memo', { + memo: memoCarolToBob, + userId: bob.id, + }, carol), + ]); + + const [resAlice, resCarol] = await Promise.all([ + api('/users/show', { + userId: bob.id, + }, alice), + api('/users/show', { + userId: bob.id, + }, carol), + ]); + + assert.strictEqual(resAlice.body.memo, memoAliceToBob); + assert.strictEqual(resCarol.body.memo, memoCarolToBob); + }); + }); }); diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 69fb4b93be..0f9145e974 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -21,6 +21,9 @@ + {{ i18n.ts.followsYou }} @@ -39,6 +42,17 @@ +
+
+