This commit is contained in:
かっこかり 2025-02-13 12:04:51 +09:00 committed by GitHub
commit 16a9ddbbae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 299 additions and 0 deletions

8
locales/index.d.ts vendored
View File

@ -5254,6 +5254,14 @@ export interface Locale extends ILocale {
*
*/
"federationDisabled": string;
/**
*
*/
"reactionStats": string;
/**
* 使100
*/
"reactionStatsDescription": string;
"_accountSettings": {
/**
*

View File

@ -1309,6 +1309,8 @@ availableRoles: "利用可能なロール"
acknowledgeNotesAndEnable: "注意事項を理解した上でオンにします。"
federationSpecified: "このサーバーはホワイトリスト連合で運用されています。管理者が指定したサーバー以外とやり取りすることはできません。"
federationDisabled: "このサーバーは連合が無効化されています。他のサーバーのユーザーとやり取りすることはできません。"
reactionStats: "リアクション統計"
reactionStatsDescription: "最も使用された絵文字リアクション上位100件を表示します"
_accountSettings:
requireSigninToViewContents: "コンテンツの表示にログインを必須にする"

View File

@ -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';

View File

@ -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<typeof meta, typeof paramDef> { // 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,
}));
});
}
}

View File

@ -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',

View File

@ -0,0 +1,111 @@
<!--
SPDX-FileCopyrightText: lqvp
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkInfo>
{{ i18n.ts.reactionStatsDescription }}
</MkInfo>
<MkSpacer v-if="tab === 'me'" :contentMax="1000" :marginMin="20">
<div class="_gaps_s">
<MkButton key="copyMyReactionsList" @click="copyToClipboard(myReactionsListMfm)"><i class="ti ti-copy"></i> {{ i18n.ts.copyContent }}</MkButton>
</div>
<div class="_gaps_s">
<p>
<Mfm key="myreactionslist" :text="myReactionsListMfm"></Mfm>
</p>
</div>
</MkSpacer>
<MkSpacer v-else-if="tab === 'site'" :contentMax="1000" :marginMin="20">
<div class="_gaps_s">
<MkButton key="copySiteReactionsList" @click="copyToClipboard(serverReactionsListMfm)"><i class="ti ti-copy"></i> {{ i18n.ts.copyContent }}</MkButton>
</div>
<div class="_gaps_s">
<p>
<Mfm key="sitereactionslist" :text="serverReactionsListMfm"></Mfm>
</p>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { misskeyApi } from '@/scripts/misskey-api';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { signinRequired } from '@/account.js';
import MkInfo from '@/components/MkInfo.vue';
import MkButton from '@/components/MkButton.vue';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
const $i = signinRequired();
const myReactionsListMfm = ref('Loading...');
const serverReactionsListMfm = ref('Loading...');
const tab = ref('me');
watch(tab, async () => {
if (tab.value === 'site' && serverReactionsListMfm.value !== 'Loading...') { return; }
if (tab.value !== 'site' && myReactionsListMfm.value !== 'Loading...') { return; }
const reactionsList = await misskeyApi('reaction-stats', { site: tab.value === 'site' });
const res = reactionsList.map((x) => `${x.reaction} ${x.count}`).join('\n');
if (tab.value === 'site') {
serverReactionsListMfm.value = res;
} else {
myReactionsListMfm.value = res;
}
}, {
deep: true,
immediate: true,
});
const headerActions = computed(() => []);
const headerTabs = computed(() => [{
key: 'me',
title: $i.username,
icon: 'ti ti-user',
}, {
key: 'site',
title: i18n.ts.instance,
icon: 'ti ti-planet',
}]);
definePageMetadata(() => ({
title: i18n.ts.reactionStats,
icon: 'ti ti-chart-bar',
}));
</script>
<style lang="scss" module>
.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>

View File

@ -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: '/',

View File

@ -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'];

View File

@ -3671,6 +3671,17 @@ declare module '../api.js' {
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:account*
*/
request<E extends 'reaction-stats', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*

View File

@ -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 };

View File

@ -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'];

View File

@ -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.