From b644567735443ae203f78dbdbe1963c252ceb1ad Mon Sep 17 00:00:00 2001 From: syuilo Date: Thu, 16 Mar 2023 17:24:49 +0900 Subject: [PATCH] feat: clip favorite Resolve #10337 --- CHANGELOG.md | 1 + locales/ja-JP.yml | 2 + .../migration/1678953978856-clip-favorite.js | 23 ++++++ .../src/core/entities/ClipEntityService.ts | 13 +++- packages/backend/src/di-symbols.ts | 1 + .../backend/src/models/RepositoryModule.ts | 10 ++- packages/backend/src/models/entities/Clip.ts | 6 ++ .../src/models/entities/ClipFavorite.ts | 33 ++++++++ packages/backend/src/models/index.ts | 3 + .../backend/src/models/json-schema/clip.ts | 13 ++++ packages/backend/src/postgres.ts | 2 + .../backend/src/server/api/EndpointsModule.ts | 12 +++ packages/backend/src/server/api/endpoints.ts | 6 ++ .../server/api/endpoints/clips/add-note.ts | 4 + .../src/server/api/endpoints/clips/create.ts | 2 +- .../server/api/endpoints/clips/favorite.ts | 76 +++++++++++++++++++ .../src/server/api/endpoints/clips/list.ts | 2 +- .../api/endpoints/clips/my-favorites.ts | 52 +++++++++++++ .../src/server/api/endpoints/clips/show.ts | 2 +- .../server/api/endpoints/clips/unfavorite.ts | 65 ++++++++++++++++ .../src/server/api/endpoints/clips/update.ts | 2 +- .../src/server/api/endpoints/notes/clips.ts | 4 +- .../src/server/api/endpoints/users/clips.ts | 2 +- packages/frontend/src/pages/clip.vue | 26 +++++++ .../frontend/src/pages/my-clips/index.vue | 56 ++++++++++++-- 25 files changed, 403 insertions(+), 15 deletions(-) create mode 100644 packages/backend/migration/1678953978856-clip-favorite.js create mode 100644 packages/backend/src/models/entities/ClipFavorite.ts create mode 100644 packages/backend/src/server/api/endpoints/clips/favorite.ts create mode 100644 packages/backend/src/server/api/endpoints/clips/my-favorites.ts create mode 100644 packages/backend/src/server/api/endpoints/clips/unfavorite.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e72d57db3b..b33668ea97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ You should also include the user name that made the change. ### Improvements - ユーザーごとにRenoteをミュートできるように - ノートごとに絵文字リアクションを受け取るか設定できるように +- クリップをお気に入りに登録できるように - ノート検索の利用可否をロールで制御可能に(デフォルトでオフ) - ロールの並び順を設定可能に - カスタム絵文字にライセンス情報を付与できるように diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index da10ec6693..c4e86fc64a 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -975,6 +975,8 @@ sensitiveWords: "センシティブワード" sensitiveWordsDescription: "設定したワードが含まれるノートの公開範囲をホームにします。改行で区切って複数設定できます。" notesSearchNotAvailable: "ノート検索は利用できません。" license: "ライセンス" +unfavoriteConfirm: "お気に入り解除しますか?" +myClips: "自分のクリップ" _achievements: earnedAt: "獲得日時" diff --git a/packages/backend/migration/1678953978856-clip-favorite.js b/packages/backend/migration/1678953978856-clip-favorite.js new file mode 100644 index 0000000000..aa5dc93a6e --- /dev/null +++ b/packages/backend/migration/1678953978856-clip-favorite.js @@ -0,0 +1,23 @@ +export class clipFavorite1678953978856 { + name = 'clipFavorite1678953978856' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "clip_favorite" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "clipId" character varying(32) NOT NULL, CONSTRAINT "PK_1b539f43906f05ebcabe752a977" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_25a31662b0b0cc9af6549a9d71" ON "clip_favorite" ("userId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_b1754a39d0b281e07ed7c078ec" ON "clip_favorite" ("userId", "clipId") `); + await queryRunner.query(`ALTER TABLE "clip" ADD "lastClippedAt" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`CREATE INDEX "IDX_a3eac04ae2aa9e221e7596114a" ON "clip" ("lastClippedAt") `); + await queryRunner.query(`ALTER TABLE "clip_favorite" ADD CONSTRAINT "FK_25a31662b0b0cc9af6549a9d711" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "clip_favorite" ADD CONSTRAINT "FK_fce61c7986cee54393e79f1d849" FOREIGN KEY ("clipId") REFERENCES "clip"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "clip_favorite" DROP CONSTRAINT "FK_fce61c7986cee54393e79f1d849"`); + await queryRunner.query(`ALTER TABLE "clip_favorite" DROP CONSTRAINT "FK_25a31662b0b0cc9af6549a9d711"`); + await queryRunner.query(`DROP INDEX "public"."IDX_a3eac04ae2aa9e221e7596114a"`); + await queryRunner.query(`ALTER TABLE "clip" DROP COLUMN "lastClippedAt"`); + await queryRunner.query(`DROP INDEX "public"."IDX_b1754a39d0b281e07ed7c078ec"`); + await queryRunner.query(`DROP INDEX "public"."IDX_25a31662b0b0cc9af6549a9d71"`); + await queryRunner.query(`DROP TABLE "clip_favorite"`); + } +} diff --git a/packages/backend/src/core/entities/ClipEntityService.ts b/packages/backend/src/core/entities/ClipEntityService.ts index fde15c8401..33d3c53806 100644 --- a/packages/backend/src/core/entities/ClipEntityService.ts +++ b/packages/backend/src/core/entities/ClipEntityService.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { ClipsRepository } from '@/models/index.js'; +import type { ClipFavoritesRepository, ClipsRepository, User } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; @@ -14,6 +14,9 @@ export class ClipEntityService { @Inject(DI.clipsRepository) private clipsRepository: ClipsRepository, + @Inject(DI.clipFavoritesRepository) + private clipFavoritesRepository: ClipFavoritesRepository, + private userEntityService: UserEntityService, ) { } @@ -21,25 +24,31 @@ export class ClipEntityService { @bindThis public async pack( src: Clip['id'] | Clip, + me?: { id: User['id'] } | null | undefined, ): Promise> { + const meId = me ? me.id : null; const clip = typeof src === 'object' ? src : await this.clipsRepository.findOneByOrFail({ id: src }); return await awaitAll({ id: clip.id, createdAt: clip.createdAt.toISOString(), + lastClippedAt: clip.lastClippedAt ? clip.lastClippedAt.toISOString() : null, userId: clip.userId, user: this.userEntityService.pack(clip.user ?? clip.userId), name: clip.name, description: clip.description, isPublic: clip.isPublic, + favoritedCount: await this.clipFavoritesRepository.countBy({ clipId: clip.id }), + isFavorited: meId ? await this.clipFavoritesRepository.findOneBy({ clipId: clip.id, userId: meId }).then(x => x != null) : undefined, }); } @bindThis public packMany( clips: Clip[], + me?: { id: User['id'] } | null | undefined, ) { - return Promise.all(clips.map(x => this.pack(x))); + return Promise.all(clips.map(x => this.pack(x, me))); } } diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 187f930ace..0879735b1d 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -52,6 +52,7 @@ export const DI = { moderationLogsRepository: Symbol('moderationLogsRepository'), clipsRepository: Symbol('clipsRepository'), clipNotesRepository: Symbol('clipNotesRepository'), + clipFavoritesRepository: Symbol('clipFavoritesRepository'), antennasRepository: Symbol('antennasRepository'), antennaNotesRepository: Symbol('antennaNotesRepository'), promoNotesRepository: Symbol('promoNotesRepository'), diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index d29b07b020..d00c8813c7 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, RenoteMuting, 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, 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, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite } from './index.js'; import type { DataSource } from 'typeorm'; import type { Provider } from '@nestjs/common'; @@ -286,6 +286,12 @@ const $clipNotesRepository: Provider = { inject: [DI.db], }; +const $clipFavoritesRepository: Provider = { + provide: DI.clipFavoritesRepository, + useFactory: (db: DataSource) => db.getRepository(ClipFavorite), + inject: [DI.db], +}; + const $antennasRepository: Provider = { provide: DI.antennasRepository, useFactory: (db: DataSource) => db.getRepository(Antenna), @@ -445,6 +451,7 @@ const $roleAssignmentsRepository: Provider = { $moderationLogsRepository, $clipsRepository, $clipNotesRepository, + $clipFavoritesRepository, $antennasRepository, $antennaNotesRepository, $promoNotesRepository, @@ -512,6 +519,7 @@ const $roleAssignmentsRepository: Provider = { $moderationLogsRepository, $clipsRepository, $clipNotesRepository, + $clipFavoritesRepository, $antennasRepository, $antennaNotesRepository, $promoNotesRepository, diff --git a/packages/backend/src/models/entities/Clip.ts b/packages/backend/src/models/entities/Clip.ts index 57a310ac03..825a32c981 100644 --- a/packages/backend/src/models/entities/Clip.ts +++ b/packages/backend/src/models/entities/Clip.ts @@ -12,6 +12,12 @@ export class Clip { }) public createdAt: Date; + @Index() + @Column('timestamp with time zone', { + nullable: true, + }) + public lastClippedAt: Date | null; + @Index() @Column({ ...id(), diff --git a/packages/backend/src/models/entities/ClipFavorite.ts b/packages/backend/src/models/entities/ClipFavorite.ts new file mode 100644 index 0000000000..623471e671 --- /dev/null +++ b/packages/backend/src/models/entities/ClipFavorite.ts @@ -0,0 +1,33 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from '../id.js'; +import { User } from './User.js'; +import { Clip } from './Clip.js'; + +@Entity() +@Index(['userId', 'clipId'], { unique: true }) +export class ClipFavorite { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone') + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Column(id()) + public clipId: Clip['id']; + + @ManyToOne(type => Clip, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public clip: Clip | null; +} diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index 4acb958b04..17083d7a01 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -13,6 +13,7 @@ import { ChannelFollowing } from '@/models/entities/ChannelFollowing.js'; import { ChannelNotePining } from '@/models/entities/ChannelNotePining.js'; import { Clip } from '@/models/entities/Clip.js'; import { ClipNote } from '@/models/entities/ClipNote.js'; +import { ClipFavorite } from '@/models/entities/ClipFavorite.js'; import { DriveFile } from '@/models/entities/DriveFile.js'; import { DriveFolder } from '@/models/entities/DriveFolder.js'; import { Emoji } from '@/models/entities/Emoji.js'; @@ -81,6 +82,7 @@ export { ChannelNotePining, Clip, ClipNote, + ClipFavorite, DriveFile, DriveFolder, Emoji, @@ -148,6 +150,7 @@ export type ChannelFollowingsRepository = Repository; export type ChannelNotePiningsRepository = Repository; export type ClipsRepository = Repository; export type ClipNotesRepository = Repository; +export type ClipFavoritesRepository = Repository; export type DriveFilesRepository = Repository; export type DriveFoldersRepository = Repository; export type EmojisRepository = Repository; diff --git a/packages/backend/src/models/json-schema/clip.ts b/packages/backend/src/models/json-schema/clip.ts index f0ee2ce0c4..7310e59013 100644 --- a/packages/backend/src/models/json-schema/clip.ts +++ b/packages/backend/src/models/json-schema/clip.ts @@ -12,6 +12,11 @@ export const packedClipSchema = { optional: false, nullable: false, format: 'date-time', }, + lastClippedAt: { + type: 'string', + optional: false, nullable: true, + format: 'date-time', + }, userId: { type: 'string', optional: false, nullable: false, @@ -34,5 +39,13 @@ export const packedClipSchema = { type: 'boolean', optional: false, nullable: false, }, + isFavorited: { + type: 'boolean', + optional: true, nullable: false, + }, + favoritedCount: { + type: 'number', + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 741985f3a1..d5428805d1 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -21,6 +21,7 @@ import { ChannelFollowing } from '@/models/entities/ChannelFollowing.js'; import { ChannelNotePining } from '@/models/entities/ChannelNotePining.js'; import { Clip } from '@/models/entities/Clip.js'; import { ClipNote } from '@/models/entities/ClipNote.js'; +import { ClipFavorite } from '@/models/entities/ClipFavorite.js'; import { DriveFile } from '@/models/entities/DriveFile.js'; import { DriveFolder } from '@/models/entities/DriveFolder.js'; import { Emoji } from '@/models/entities/Emoji.js'; @@ -165,6 +166,7 @@ export const entities = [ ModerationLog, Clip, ClipNote, + ClipFavorite, Antenna, AntennaNote, PromoNote, diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 2724649590..76fb8f636b 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -114,6 +114,9 @@ import * as ep___clips_list from './endpoints/clips/list.js'; import * as ep___clips_notes from './endpoints/clips/notes.js'; import * as ep___clips_show from './endpoints/clips/show.js'; import * as ep___clips_update from './endpoints/clips/update.js'; +import * as ep___clips_favorite from './endpoints/clips/favorite.js'; +import * as ep___clips_unfavorite from './endpoints/clips/unfavorite.js'; +import * as ep___clips_myFavorites from './endpoints/clips/my-favorites.js'; import * as ep___drive from './endpoints/drive.js'; import * as ep___drive_files from './endpoints/drive/files.js'; import * as ep___drive_files_attachedNotes from './endpoints/drive/files/attached-notes.js'; @@ -438,6 +441,9 @@ const $clips_list: Provider = { provide: 'ep:clips/list', useClass: ep___clips_l const $clips_notes: Provider = { provide: 'ep:clips/notes', useClass: ep___clips_notes.default }; const $clips_show: Provider = { provide: 'ep:clips/show', useClass: ep___clips_show.default }; const $clips_update: Provider = { provide: 'ep:clips/update', useClass: ep___clips_update.default }; +const $clips_favorite: Provider = { provide: 'ep:clips/favorite', useClass: ep___clips_favorite.default }; +const $clips_unfavorite: Provider = { provide: 'ep:clips/unfavorite', useClass: ep___clips_unfavorite.default }; +const $clips_myFavorites: Provider = { provide: 'ep:clips/my-favorites', useClass: ep___clips_myFavorites.default }; const $drive: Provider = { provide: 'ep:drive', useClass: ep___drive.default }; const $drive_files: Provider = { provide: 'ep:drive/files', useClass: ep___drive_files.default }; const $drive_files_attachedNotes: Provider = { provide: 'ep:drive/files/attached-notes', useClass: ep___drive_files_attachedNotes.default }; @@ -766,6 +772,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $clips_notes, $clips_show, $clips_update, + $clips_favorite, + $clips_unfavorite, + $clips_myFavorites, $drive, $drive_files, $drive_files_attachedNotes, @@ -1088,6 +1097,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $clips_notes, $clips_show, $clips_update, + $clips_favorite, + $clips_unfavorite, + $clips_myFavorites, $drive, $drive_files, $drive_files_attachedNotes, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 58f4fcc8a8..e928b0c2b1 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -114,6 +114,9 @@ import * as ep___clips_list from './endpoints/clips/list.js'; import * as ep___clips_notes from './endpoints/clips/notes.js'; import * as ep___clips_show from './endpoints/clips/show.js'; import * as ep___clips_update from './endpoints/clips/update.js'; +import * as ep___clips_favorite from './endpoints/clips/favorite.js'; +import * as ep___clips_unfavorite from './endpoints/clips/unfavorite.js'; +import * as ep___clips_myFavorites from './endpoints/clips/my-favorites.js'; import * as ep___drive from './endpoints/drive.js'; import * as ep___drive_files from './endpoints/drive/files.js'; import * as ep___drive_files_attachedNotes from './endpoints/drive/files/attached-notes.js'; @@ -436,6 +439,9 @@ const eps = [ ['clips/notes', ep___clips_notes], ['clips/show', ep___clips_show], ['clips/update', ep___clips_update], + ['clips/favorite', ep___clips_favorite], + ['clips/unfavorite', ep___clips_unfavorite], + ['clips/my-favorites', ep___clips_myFavorites], ['drive', ep___drive], ['drive/files', ep___drive_files], ['drive/files/attached-notes', ep___drive_files_attachedNotes], diff --git a/packages/backend/src/server/api/endpoints/clips/add-note.ts b/packages/backend/src/server/api/endpoints/clips/add-note.ts index f3f9c3477f..b9d8dce47a 100644 --- a/packages/backend/src/server/api/endpoints/clips/add-note.ts +++ b/packages/backend/src/server/api/endpoints/clips/add-note.ts @@ -106,6 +106,10 @@ export default class extends Endpoint { noteId: note.id, clipId: clip.id, }); + + await this.clipsRepository.update(clip.id, { + lastClippedAt: new Date(), + }); }); } } diff --git a/packages/backend/src/server/api/endpoints/clips/create.ts b/packages/backend/src/server/api/endpoints/clips/create.ts index c095de702c..a770dc986d 100644 --- a/packages/backend/src/server/api/endpoints/clips/create.ts +++ b/packages/backend/src/server/api/endpoints/clips/create.ts @@ -67,7 +67,7 @@ export default class extends Endpoint { description: ps.description, }).then(x => this.clipsRepository.findOneByOrFail(x.identifiers[0])); - return await this.clipEntityService.pack(clip); + return await this.clipEntityService.pack(clip, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/clips/favorite.ts b/packages/backend/src/server/api/endpoints/clips/favorite.ts new file mode 100644 index 0000000000..6addf743a2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/favorite.ts @@ -0,0 +1,76 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { ClipsRepository, ClipFavoritesRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['clip'], + + requireCredential: true, + + kind: 'write:clip-favorite', + + errors: { + noSuchClip: { + message: 'No such clip.', + code: 'NO_SUCH_CLIP', + id: '4c2aaeae-80d8-4250-9606-26cb1fdb77a5', + }, + + alreadyFavorited: { + message: 'The clip has already been favorited.', + code: 'ALREADY_FAVORITED', + id: '92658936-c625-4273-8326-2d790129256e', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + clipId: { type: 'string', format: 'misskey:id' }, + }, + required: ['clipId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.clipsRepository) + private clipsRepository: ClipsRepository, + + @Inject(DI.clipFavoritesRepository) + private clipFavoritesRepository: ClipFavoritesRepository, + + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const clip = await this.clipsRepository.findOneBy({ id: ps.clipId }); + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + if ((clip.userId !== me.id) && !clip.isPublic) { + throw new ApiError(meta.errors.noSuchClip); + } + + const exist = await this.clipFavoritesRepository.findOneBy({ + clipId: clip.id, + userId: me.id, + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyFavorited); + } + + await this.clipFavoritesRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + clipId: clip.id, + userId: me.id, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/clips/list.ts b/packages/backend/src/server/api/endpoints/clips/list.ts index 63ca069364..3b8deab709 100644 --- a/packages/backend/src/server/api/endpoints/clips/list.ts +++ b/packages/backend/src/server/api/endpoints/clips/list.ts @@ -42,7 +42,7 @@ export default class extends Endpoint { userId: me.id, }); - return await Promise.all(clips.map(x => this.clipEntityService.pack(x))); + return await this.clipEntityService.packMany(clips, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/clips/my-favorites.ts b/packages/backend/src/server/api/endpoints/clips/my-favorites.ts new file mode 100644 index 0000000000..fc727e93bd --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/my-favorites.ts @@ -0,0 +1,52 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { ClipFavoritesRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; + +export const meta = { + tags: ['account', 'clip'], + + requireCredential: true, + + kind: 'read:clip-favorite', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Clip', + }, + }, +} 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.clipFavoritesRepository) + private clipFavoritesRepository: ClipFavoritesRepository, + + private clipEntityService: ClipEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.clipFavoritesRepository.createQueryBuilder('favorite') + .andWhere('favorite.userId = :meId', { meId: me.id }) + .leftJoinAndSelect('favorite.clip', 'clip'); + + const favorites = await query + .getMany(); + + return this.clipEntityService.packMany(favorites.map(x => x.clip!), me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/clips/show.ts b/packages/backend/src/server/api/endpoints/clips/show.ts index e6d3f4f1f8..99d630a9b5 100644 --- a/packages/backend/src/server/api/endpoints/clips/show.ts +++ b/packages/backend/src/server/api/endpoints/clips/show.ts @@ -58,7 +58,7 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchClip); } - return await this.clipEntityService.pack(clip); + return await this.clipEntityService.pack(clip, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/clips/unfavorite.ts b/packages/backend/src/server/api/endpoints/clips/unfavorite.ts new file mode 100644 index 0000000000..244843d50f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/unfavorite.ts @@ -0,0 +1,65 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { ClipsRepository, ClipFavoritesRepository } 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: ['clip'], + + requireCredential: true, + + kind: 'write:clip-favorite', + + errors: { + noSuchClip: { + message: 'No such clip.', + code: 'NO_SUCH_CLIP', + id: '2603966e-b865-426c-94a7-af4a01241dc1', + }, + + notFavorited: { + message: 'You have not favorited the clip.', + code: 'NOT_FAVORITED', + id: '90c3a9e8-b321-4dae-bf57-2bf79bbcc187', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + clipId: { type: 'string', format: 'misskey:id' }, + }, + required: ['clipId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.clipsRepository) + private clipsRepository: ClipsRepository, + + @Inject(DI.clipFavoritesRepository) + private clipFavoritesRepository: ClipFavoritesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const clip = await this.clipsRepository.findOneBy({ id: ps.clipId }); + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + const exist = await this.clipFavoritesRepository.findOneBy({ + clipId: clip.id, + userId: me.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notFavorited); + } + + await this.clipFavoritesRepository.delete(exist.id); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/clips/update.ts b/packages/backend/src/server/api/endpoints/clips/update.ts index 597b67c442..a103c3f7d3 100644 --- a/packages/backend/src/server/api/endpoints/clips/update.ts +++ b/packages/backend/src/server/api/endpoints/clips/update.ts @@ -64,7 +64,7 @@ export default class extends Endpoint { isPublic: ps.isPublic, }); - return await this.clipEntityService.pack(clip.id); + return await this.clipEntityService.pack(clip.id, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/notes/clips.ts b/packages/backend/src/server/api/endpoints/notes/clips.ts index d5caec6e1d..0a5542f497 100644 --- a/packages/backend/src/server/api/endpoints/notes/clips.ts +++ b/packages/backend/src/server/api/endpoints/notes/clips.ts @@ -4,8 +4,8 @@ import type { ClipNotesRepository, ClipsRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['clips', 'notes'], @@ -67,7 +67,7 @@ export default class extends Endpoint { isPublic: true, }); - return await Promise.all(clips.map(x => this.clipEntityService.pack(x))); + return await this.clipEntityService.packMany(clips, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/users/clips.ts b/packages/backend/src/server/api/endpoints/users/clips.ts index e3fd0920c9..c5aa93baaf 100644 --- a/packages/backend/src/server/api/endpoints/users/clips.ts +++ b/packages/backend/src/server/api/endpoints/users/clips.ts @@ -51,7 +51,7 @@ export default class extends Endpoint { .take(ps.limit) .getMany(); - return await this.clipEntityService.packMany(clips); + return await this.clipEntityService.packMany(clips, me); }); } } diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue index d66088d33a..7515a9122a 100644 --- a/packages/frontend/src/pages/clip.vue +++ b/packages/frontend/src/pages/clip.vue @@ -7,6 +7,8 @@
+ {{ clip.favoritedCount }} + {{ clip.favoritedCount }}
@@ -27,12 +29,14 @@ import { i18n } from '@/i18n'; import * as os from '@/os'; import { definePageMetadata } from '@/scripts/page-metadata'; import { url } from '@/config'; +import MkButton from '@/components/MkButton.vue'; const props = defineProps<{ clipId: string, }>(); let clip: misskey.entities.Clip = $ref(); +let favorited = $ref(false); const pagination = { endpoint: 'clips/notes' as const, limit: 10, @@ -47,12 +51,34 @@ watch(() => props.clipId, async () => { clip = await os.api('clips/show', { clipId: props.clipId, }); + favorited = clip.isFavorited; }, { immediate: true, }); provide('currentClipPage', $$(clip)); +function favorite() { + os.apiWithDialog('clips/favorite', { + clipId: props.clipId, + }).then(() => { + favorited = true; + }); +} + +async function unfavorite() { + const confirm = await os.confirm({ + type: 'warning', + text: i18n.ts.unfavoriteConfirm, + }); + if (confirm.canceled) return; + os.apiWithDialog('clips/unfavorite', { + clipId: props.clipId, + }).then(() => { + favorited = false; + }); +} + const headerActions = $computed(() => clip && isOwned ? [{ icon: 'ti ti-pencil', text: i18n.ts.edit, diff --git a/packages/frontend/src/pages/my-clips/index.vue b/packages/frontend/src/pages/my-clips/index.vue index a79601f32f..ad5f95e607 100644 --- a/packages/frontend/src/pages/my-clips/index.vue +++ b/packages/frontend/src/pages/my-clips/index.vue @@ -1,9 +1,9 @@