Compare commits
8 Commits
d41251768d
...
16a9ddbbae
Author | SHA1 | Date |
---|---|---|
|
16a9ddbbae | |
|
30df768d26 | |
|
ebd06becbf | |
|
0b47e8c746 | |
|
cafba63ddb | |
|
f633cfd302 | |
|
1fd5ebc832 | |
|
7ef7f575a4 |
|
@ -14,6 +14,7 @@
|
|||
|
||||
### Server
|
||||
- Fix: `following/invalidate`でフォロワーを解除しようとしているユーザーの情報を返すように
|
||||
- Fix: オブジェクトストレージの設定でPrefixを設定していなかった場合nullまたは空文字になる問題を修正
|
||||
|
||||
|
||||
## 2025.2.0
|
||||
|
|
|
@ -5254,6 +5254,14 @@ export interface Locale extends ILocale {
|
|||
* このサーバーは連合が無効化されています。他のサーバーのユーザーとやり取りすることはできません。
|
||||
*/
|
||||
"federationDisabled": string;
|
||||
/**
|
||||
* リアクション統計
|
||||
*/
|
||||
"reactionStats": string;
|
||||
/**
|
||||
* 最も使用された絵文字リアクション上位100件を表示します
|
||||
*/
|
||||
"reactionStatsDescription": string;
|
||||
"_accountSettings": {
|
||||
/**
|
||||
* コンテンツの表示にログインを必須にする
|
||||
|
|
|
@ -1309,6 +1309,8 @@ availableRoles: "利用可能なロール"
|
|||
acknowledgeNotesAndEnable: "注意事項を理解した上でオンにします。"
|
||||
federationSpecified: "このサーバーはホワイトリスト連合で運用されています。管理者が指定したサーバー以外とやり取りすることはできません。"
|
||||
federationDisabled: "このサーバーは連合が無効化されています。他のサーバーのユーザーとやり取りすることはできません。"
|
||||
reactionStats: "リアクション統計"
|
||||
reactionStatsDescription: "最も使用された絵文字リアクション上位100件を表示します"
|
||||
|
||||
_accountSettings:
|
||||
requireSigninToViewContents: "コンテンツの表示にログインを必須にする"
|
||||
|
|
|
@ -173,7 +173,8 @@ export class DriveService {
|
|||
?? `${ this.meta.objectStorageUseSSL ? 'https' : 'http' }://${ this.meta.objectStorageEndpoint }${ this.meta.objectStoragePort ? `:${this.meta.objectStoragePort}` : '' }/${ this.meta.objectStorageBucket }`;
|
||||
|
||||
// for original
|
||||
const key = `${this.meta.objectStoragePrefix}/${randomUUID()}${ext}`;
|
||||
const prefix = this.meta.objectStoragePrefix ? `${this.meta.objectStoragePrefix}/` : '';
|
||||
const key = `${prefix}${randomUUID()}${ext}`;
|
||||
const url = `${ baseUrl }/${ key }`;
|
||||
|
||||
// for alts
|
||||
|
@ -190,7 +191,7 @@ export class DriveService {
|
|||
];
|
||||
|
||||
if (alts.webpublic) {
|
||||
webpublicKey = `${this.meta.objectStoragePrefix}/webpublic-${randomUUID()}.${alts.webpublic.ext}`;
|
||||
webpublicKey = `${prefix}webpublic-${randomUUID()}.${alts.webpublic.ext}`;
|
||||
webpublicUrl = `${ baseUrl }/${ webpublicKey }`;
|
||||
|
||||
this.registerLogger.info(`uploading webpublic: ${webpublicKey}`);
|
||||
|
@ -198,7 +199,7 @@ export class DriveService {
|
|||
}
|
||||
|
||||
if (alts.thumbnail) {
|
||||
thumbnailKey = `${this.meta.objectStoragePrefix}/thumbnail-${randomUUID()}.${alts.thumbnail.ext}`;
|
||||
thumbnailKey = `${prefix}thumbnail-${randomUUID()}.${alts.thumbnail.ext}`;
|
||||
thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`;
|
||||
|
||||
this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`);
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
|
|
|
@ -109,6 +109,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div>
|
||||
<a style="display: inline-block;" class="pepabo" title="GMO Pepabo" href="https://pepabo.com/" target="_blank"><img style="width: 100%;" src="https://assets.misskey-hub.net/sponsors/gmo_pepabo.svg" alt="GMO Pepabo"></a>
|
||||
</div>
|
||||
<div>
|
||||
<a style="display: inline-block;" class="purpledotdigital" title="Purple Dot Digital" href="https://purpledotdigital.com/" target="_blank"><img style="width: 100%;" src="https://assets.misskey-hub.net/sponsors/purple-dot-digital.jpg" alt="Purple Dot Digital"></a>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
<FormSection>
|
||||
|
|
|
@ -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>
|
|
@ -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: '/',
|
||||
|
|
|
@ -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'];
|
||||
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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'];
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Reference in New Issue