diff --git a/locales/index.d.ts b/locales/index.d.ts index 4fe605490e..e90e8a8c2c 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5254,6 +5254,14 @@ export interface Locale extends ILocale { * このサーバーは連合が無効化されています。他のサーバーのユーザーとやり取りすることはできません。 */ "federationDisabled": string; + /** + * リアクション統計 + */ + "reactionStats": string; + /** + * 最も使用された絵文字リアクション上位100件を表示します + */ + "reactionStatsDescription": string; "_accountSettings": { /** * コンテンツの表示にログインを必須にする diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 57b11e9e04..ffc192ebc7 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1309,6 +1309,8 @@ availableRoles: "利用可能なロール" acknowledgeNotesAndEnable: "注意事項を理解した上でオンにします。" federationSpecified: "このサーバーはホワイトリスト連合で運用されています。管理者が指定したサーバー以外とやり取りすることはできません。" federationDisabled: "このサーバーは連合が無効化されています。他のサーバーのユーザーとやり取りすることはできません。" +reactionStats: "リアクション統計" +reactionStatsDescription: "最も使用された絵文字リアクション上位100件を表示します" _accountSettings: requireSigninToViewContents: "コンテンツの表示にログインを必須にする" diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index 28f7cfea04..d997c5018c 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -339,6 +339,7 @@ export * as 'pages/update' from './endpoints/pages/update.js'; export * as 'ping' from './endpoints/ping.js'; export * as 'pinned-users' from './endpoints/pinned-users.js'; export * as 'promo/read' from './endpoints/promo/read.js'; +export * as 'reaction-stats' from './endpoints/reaction-stats.js'; export * as 'renote-mute/create' from './endpoints/renote-mute/create.js'; export * as 'renote-mute/delete' from './endpoints/renote-mute/delete.js'; export * as 'renote-mute/list' from './endpoints/renote-mute/list.js'; diff --git a/packages/backend/src/server/api/endpoints/reaction-stats.ts b/packages/backend/src/server/api/endpoints/reaction-stats.ts new file mode 100644 index 0000000000..313ea4b69a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/reaction-stats.ts @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: lqvp + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { NoteReactionsRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['meta'], + + requireCredential: true, + kind: 'read:account', + + res: { + type: 'array', + items: { + type: 'object', + properties: { + reaction: { + type: 'string', + optional: false, + }, + count: { + type: 'number', + optional: false, + }, + }, + }, + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '27e494ba-2ac2-48e8-893b-10d4d8c2387b', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + site: { type: 'boolean', default: false }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.noteReactionsRepository) + private noteReactionsRepository: NoteReactionsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const query = + this.noteReactionsRepository.createQueryBuilder('nr') + .select('nr.reaction', 'reaction') + .addSelect('count(nr.id)', 'count') + .groupBy('nr.reaction') + .orderBy('count', 'DESC') + .limit(100); + + if (!ps.site) { + query.where('nr.userId = :id', { id: me.id }); + } + + const res = await query.getRawMany(); + + return res.map(x => ({ + reaction: x.reaction, + count: x.count, + })); + }); + } +} diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts index 096d404a57..404618967e 100644 --- a/packages/frontend/src/navbar.ts +++ b/packages/frontend/src/navbar.ts @@ -176,6 +176,11 @@ export const navbarItemDef = reactive({ show: computed(() => $i != null), to: `/@${$i?.username}`, }, + reactionStats: { + title: i18n.ts.reactionStats, + icon: 'ti ti-chart-bar', + to: '/reaction-stats', + }, cacheClear: { title: i18n.ts.clearCache, icon: 'ti ti-trash', diff --git a/packages/frontend/src/pages/reaction-stats.vue b/packages/frontend/src/pages/reaction-stats.vue new file mode 100644 index 0000000000..9164a6dc44 --- /dev/null +++ b/packages/frontend/src/pages/reaction-stats.vue @@ -0,0 +1,111 @@ + + + + + + + diff --git a/packages/frontend/src/router/definition.ts b/packages/frontend/src/router/definition.ts index d2a4484c45..f4a8852691 100644 --- a/packages/frontend/src/router/definition.ts +++ b/packages/frontend/src/router/definition.ts @@ -575,6 +575,10 @@ const routes: RouteDef[] = [{ }, { path: '/timeline', component: page(() => import('@/pages/timeline.vue')), +}, { + path: '/reactions-stats', + component: page(() => import('@/pages/reaction-stats.vue')), + loginRequired: true, }, { name: 'index', path: '/', diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index ac7babb250..ac671947c1 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -1724,6 +1724,8 @@ declare namespace entities { PingResponse, PinnedUsersResponse, PromoReadRequest, + ReactionStatsRequest, + ReactionStatsResponse, RenoteMuteCreateRequest, RenoteMuteDeleteRequest, RenoteMuteListRequest, @@ -2937,6 +2939,12 @@ type QueueStats = { // @public (undocumented) type QueueStatsLog = QueueStats[]; +// @public (undocumented) +type ReactionStatsRequest = operations['reaction-stats']['requestBody']['content']['application/json']; + +// @public (undocumented) +type ReactionStatsResponse = operations['reaction-stats']['responses']['200']['content']['application/json']; + // @public (undocumented) type RenoteMuteCreateRequest = operations['renote-mute___create']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index 6bace3924c..506279a0b0 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -3671,6 +3671,17 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:account* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * No description provided. * diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index a9903b9139..ea4bde9797 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -489,6 +489,8 @@ import type { PingResponse, PinnedUsersResponse, PromoReadRequest, + ReactionStatsRequest, + ReactionStatsResponse, RenoteMuteCreateRequest, RenoteMuteDeleteRequest, RenoteMuteListRequest, @@ -915,6 +917,7 @@ export type Endpoints = { 'ping': { req: EmptyRequest; res: PingResponse }; 'pinned-users': { req: EmptyRequest; res: PinnedUsersResponse }; 'promo/read': { req: PromoReadRequest; res: EmptyResponse }; + 'reaction-stats': { req: ReactionStatsRequest; res: ReactionStatsResponse }; 'renote-mute/create': { req: RenoteMuteCreateRequest; res: EmptyResponse }; 'renote-mute/delete': { req: RenoteMuteDeleteRequest; res: EmptyResponse }; 'renote-mute/list': { req: RenoteMuteListRequest; res: RenoteMuteListResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index b7639abca8..f1285dc49b 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -492,6 +492,8 @@ export type PagesUpdateRequest = operations['pages___update']['requestBody']['co export type PingResponse = operations['ping']['responses']['200']['content']['application/json']; export type PinnedUsersResponse = operations['pinned-users']['responses']['200']['content']['application/json']; export type PromoReadRequest = operations['promo___read']['requestBody']['content']['application/json']; +export type ReactionStatsRequest = operations['reaction-stats']['requestBody']['content']['application/json']; +export type ReactionStatsResponse = operations['reaction-stats']['responses']['200']['content']['application/json']; export type RenoteMuteCreateRequest = operations['renote-mute___create']['requestBody']['content']['application/json']; export type RenoteMuteDeleteRequest = operations['renote-mute___delete']['requestBody']['content']['application/json']; export type RenoteMuteListRequest = operations['renote-mute___list']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index c7485c6c3d..734d38da52 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -3176,6 +3176,15 @@ export type paths = { */ post: operations['promo___read']; }; + '/reaction-stats': { + /** + * reaction-stats + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:account* + */ + post: operations['reaction-stats']; + }; '/renote-mute/create': { /** * renote-mute/create @@ -24856,6 +24865,63 @@ export type operations = { }; }; }; + /** + * reaction-stats + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:account* + */ + 'reaction-stats': { + requestBody: { + content: { + 'application/json': { + /** @default false */ + site?: boolean; + }; + }; + }; + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': { + reaction: string; + count: number; + }[]; + }; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; /** * renote-mute/create * @description No description provided.