diff --git a/CHANGELOG.md b/CHANGELOG.md index 873716a3d5..1139707b5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,19 @@ ## 12.x.x (unreleased) ### Improvements -- ページロードエラーページにリロードボタンを追加 ### Bugfixes --> +## 12.x.x (unreleased) + +### Improvements +- クライアント: ユーザーのリアクション一覧を見れるように +- API: ユーザーのリアクション一覧を取得する users/reactions を追加 + +### Bugfixes + ## 12.92.0 (2021/10/16) ### Improvements diff --git a/src/client/pages/user/index.vue b/src/client/pages/user/index.vue index 0ddf73d572..6811dff2db 100644 --- a/src/client/pages/user/index.vue +++ b/src/client/pages/user/index.vue @@ -181,6 +181,7 @@ </template> <XFollowList v-else-if="page === 'following'" type="following" :user="user" class="_content _gap"/> <XFollowList v-else-if="page === 'followers'" type="followers" :user="user" class="_content _gap"/> + <XReactions v-else-if="page === 'reactions'" :user="user" class="_gap"/> <XClips v-else-if="page === 'clips'" :user="user" class="_gap"/> <XPages v-else-if="page === 'pages'" :user="user" class="_gap"/> <XGallery v-else-if="page === 'gallery'" :user="user" class="_gap"/> @@ -223,6 +224,7 @@ export default defineComponent({ MkTab, MkInfo, XFollowList: defineAsyncComponent(() => import('./follow-list.vue')), + XReactions: defineAsyncComponent(() => import('./reactions.vue')), XClips: defineAsyncComponent(() => import('./clips.vue')), XPages: defineAsyncComponent(() => import('./pages.vue')), XGallery: defineAsyncComponent(() => import('./gallery.vue')), @@ -268,6 +270,11 @@ export default defineComponent({ title: this.$ts.overview, icon: 'fas fa-home', onClick: () => { this.$router.push('/@' + getAcct(this.user)); }, + }, { + active: this.page === 'reactions', + title: this.$ts.reaction, + icon: 'fas fa-laugh', + onClick: () => { this.$router.push('/@' + getAcct(this.user) + '/reactions'); }, }, { active: this.page === 'clips', title: this.$ts.clips, diff --git a/src/client/pages/user/reactions.vue b/src/client/pages/user/reactions.vue new file mode 100644 index 0000000000..5ac7e01027 --- /dev/null +++ b/src/client/pages/user/reactions.vue @@ -0,0 +1,81 @@ +<template> +<div> + <MkPagination :pagination="pagination" #default="{items}" ref="list"> + <div v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap afdcfbfb"> + <div class="header"> + <MkAvatar class="avatar" :user="user"/> + <MkReactionIcon class="reaction" :reaction="item.type" :custom-emojis="item.note.emojis" :no-style="true"/> + <MkTime :time="item.createdAt" class="createdAt"/> + </div> + <MkNote :note="item.note" @update:note="updated(note, $event)" :key="item.id"/> + </div> + </MkPagination> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkPagination from '@client/components/ui/pagination.vue'; +import MkNote from '@client/components/note.vue'; +import MkReactionIcon from '@client/components/reaction-icon.vue'; + +export default defineComponent({ + components: { + MkPagination, + MkNote, + MkReactionIcon, + }, + + props: { + user: { + type: Object, + required: true + }, + }, + + data() { + return { + pagination: { + endpoint: 'users/reactions', + limit: 20, + params: { + userId: this.user.id, + } + }, + }; + }, + + watch: { + user() { + this.$refs.list.reload(); + } + }, +}); +</script> + +<style lang="scss" scoped> +.afdcfbfb { + > .header { + display: flex; + align-items: center; + padding: 8px 16px; + margin-bottom: 8px; + border-bottom: solid 2px var(--divider); + + > .avatar { + width: 24px; + height: 24px; + margin-right: 8px; + } + + > .reaction { + width: 32px; + height: 32px; + } + + > .createdAt { + margin-left: auto; + } + } +} +</style> diff --git a/src/models/repositories/note-reaction.ts b/src/models/repositories/note-reaction.ts index ba74076f6c..5d86065526 100644 --- a/src/models/repositories/note-reaction.ts +++ b/src/models/repositories/note-reaction.ts @@ -1,6 +1,6 @@ import { EntityRepository, Repository } from 'typeorm'; import { NoteReaction } from '@/models/entities/note-reaction'; -import { Users } from '../index'; +import { Notes, Users } from '../index'; import { Packed } from '@/misc/schema'; import { convertLegacyReaction } from '@/misc/reaction-lib'; import { User } from '@/models/entities/user'; @@ -9,8 +9,15 @@ import { User } from '@/models/entities/user'; export class NoteReactionRepository extends Repository<NoteReaction> { public async pack( src: NoteReaction['id'] | NoteReaction, - me?: { id: User['id'] } | null | undefined + me?: { id: User['id'] } | null | undefined, + options?: { + withNote: boolean; + }, ): Promise<Packed<'NoteReaction'>> { + const opts = Object.assign({ + withNote: false, + }, options); + const reaction = typeof src === 'object' ? src : await this.findOneOrFail(src); return { @@ -18,6 +25,9 @@ export class NoteReactionRepository extends Repository<NoteReaction> { createdAt: reaction.createdAt.toISOString(), user: await Users.pack(reaction.userId, me), type: convertLegacyReaction(reaction.reaction), + ...(opts.withNote ? { + note: await Notes.pack(reaction.noteId, me), + } : {}) }; } } diff --git a/src/server/api/endpoints/users/reactions.ts b/src/server/api/endpoints/users/reactions.ts new file mode 100644 index 0000000000..44d7887482 --- /dev/null +++ b/src/server/api/endpoints/users/reactions.ts @@ -0,0 +1,67 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { NoteReactions } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; + +export const meta = { + tags: ['users', 'reactions'], + + requireCredential: false as const, + + params: { + userId: { + validator: $.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10, + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + sinceDate: { + validator: $.optional.num, + }, + + untilDate: { + validator: $.optional.num, + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'NoteReaction', + } + }, + + errors: { + } +}; + +export default define(meta, async (ps, me) => { + const query = makePaginationQuery(NoteReactions.createQueryBuilder('reaction'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere(`reaction.userId = :userId`, { userId: ps.userId }) + .leftJoinAndSelect('reaction.note', 'note'); + + generateVisibilityQuery(query, me); + + const reactions = await query + .take(ps.limit!) + .getMany(); + + return await Promise.all(reactions.map(reaction => NoteReactions.pack(reaction, me, { withNote: true }))); +});