This commit is contained in:
Porlam Nicla 2025-12-24 06:46:23 +00:00 committed by GitHub
commit c68ff2f8d2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 246 additions and 20 deletions

View File

@ -4,7 +4,7 @@
-
### Client
-
- Enhance: ノート詳細ページでリプライ一覧と引用一覧を別々に表示するように
### Server
-

View File

@ -1209,7 +1209,9 @@ youHaveUnreadAnnouncements: "未読のお知らせがあります。"
useSecurityKey: "ブラウザまたはデバイスの指示に従って、セキュリティキーまたはパスキーを使用してください。"
replies: "返信"
renotes: "リノート"
quotes: "引用"
loadReplies: "返信を見る"
loadQuotes: "引用を見る"
loadConversation: "会話を見る"
pinnedList: "ピン留めされたリスト"
keepScreenOn: "デバイスの画面を常にオンにする"

View File

@ -327,6 +327,7 @@ export * as 'notes/local-timeline' from './endpoints/notes/local-timeline.js';
export * as 'notes/mentions' from './endpoints/notes/mentions.js';
export * as 'notes/polls/recommendation' from './endpoints/notes/polls/recommendation.js';
export * as 'notes/polls/vote' from './endpoints/notes/polls/vote.js';
export * as 'notes/quotes' from './endpoints/notes/quotes.js';
export * as 'notes/reactions' from './endpoints/notes/reactions.js';
export * as 'notes/reactions/create' from './endpoints/notes/reactions/create.js';
export * as 'notes/reactions/delete' from './endpoints/notes/reactions/delete.js';

View File

@ -0,0 +1,77 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js';
export const meta = {
tags: ['notes'],
requireCredential: false,
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'Note',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
noteId: { type: 'string', format: 'misskey:id' },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
sinceDate: { type: 'integer' },
untilDate: { type: 'integer' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
},
required: ['noteId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
private noteEntityService: NoteEntityService,
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere(new Brackets(qb => {
qb
.where('note.renoteId = :noteId', { noteId: ps.noteId })
.andWhere(new Brackets(qb => {
qb
.where('note.text IS NOT NULL')
.orWhere('note.fileIds != \'{}\'')
.orWhere('note.hasPoll = TRUE');
}))
.andWhere('note.replyId IS NULL');
}))
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBaseNoteFilteringQuery(query, me);
const notes = await query.limit(ps.limit).getMany();
return await this.noteEntityService.packMany(notes, me);
});
}
}

View File

@ -181,14 +181,19 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.tabs">
<button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'replies' }]" @click="tab = 'replies'"><i class="ti ti-arrow-back-up"></i> {{ i18n.ts.replies }}</button>
<button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'renotes' }]" @click="tab = 'renotes'"><i class="ti ti-repeat"></i> {{ i18n.ts.renotes }}</button>
<button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'quotes' }]" @click="tab = 'quotes'"><i class="ti ti-quote"></i> {{ i18n.ts.quotes }}</button>
<button class="_button" :class="[$style.tab, { [$style.tabActive]: tab === 'reactions' }]" @click="tab = 'reactions'"><i class="ti ti-icons"></i> {{ i18n.ts.reactions }}</button>
</div>
<div>
<div v-if="tab === 'replies'">
<div v-if="!repliesLoaded" style="padding: 16px">
<MkButton style="margin: 0 auto;" primary rounded @click="loadReplies">{{ i18n.ts.loadReplies }}</MkButton>
<div v-if="!showReplies" style="padding: 16px">
<MkButton style="margin: 0 auto;" primary rounded @click="showReplies = true">{{ i18n.ts.loadReplies }}</MkButton>
</div>
<MkNoteSub v-for="note in replies" :key="note.id" :note="note" :class="$style.reply" :detail="true"/>
<MkPagination v-else :paginator="repliesPaginator" :forceDisableInfiniteScroll="true">
<template #default="{ items }">
<MkNoteSub v-for="item, index in items" :key="item.id" :note="item" :class="{ [$style.replyBorder]: (index > 0) }" :detail="true"/>
</template>
</MkPagination>
</div>
<div v-else-if="tab === 'renotes'" :class="$style.tab_renotes">
<MkPagination :paginator="renotesPaginator" :forceDisableInfiniteScroll="true">
@ -201,6 +206,16 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
</MkPagination>
</div>
<div v-else-if="tab === 'quotes'" >
<div v-if="!showQuotes" style="padding: 16px">
<MkButton style="margin: 0 auto;" primary rounded @click="showQuotes = true">{{ i18n.ts.loadQuotes }}</MkButton>
</div>
<MkPagination v-else :paginator="quotesPaginator" :forceDisableInfiniteScroll="true">
<template #default="{ items }">
<MkNoteSub v-for="note in items" :key="note.id" :note="note" :class="$style.quote" :detail="true"/>
</template>
</MkPagination>
</div>
<div v-else-if="tab === 'reactions'" :class="$style.tab_reactions">
<div :class="$style.reactionTabs">
<button v-for="reaction in Object.keys($appearNote.reactions)" :key="reaction" :class="[$style.reactionTab, { [$style.reactionTabActive]: reactionTabType === reaction }]" class="_button" @click="reactionTabType = reaction">
@ -335,8 +350,9 @@ const parsed = appearNote.text ? mfm.parse(appearNote.text) : null;
const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.renote?.url !== url && appearNote.renote?.uri !== url) : null;
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance);
const conversation = ref<Misskey.entities.Note[]>([]);
const replies = ref<Misskey.entities.Note[]>([]);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i?.id);
const showReplies = ref(false);
const showQuotes = ref(false);
useGlobalEvent('noteDeleted', (noteId) => {
if (noteId === note.id || noteId === appearNote.id) {
@ -386,6 +402,13 @@ provide(DI.mfmEmojiReactCallback, (reaction) => {
const tab = ref(props.initialTab);
const reactionTabType = ref<string | null>(null);
const repliesPaginator = markRaw(new Paginator('notes/replies', {
limit: 10,
params: {
noteId: appearNote.id,
},
}));
const renotesPaginator = markRaw(new Paginator('notes/renotes', {
limit: 10,
params: {
@ -393,6 +416,13 @@ const renotesPaginator = markRaw(new Paginator('notes/renotes', {
},
}));
const quotesPaginator = markRaw(new Paginator('notes/quotes', {
limit: 10,
params: {
noteId: appearNote.id,
},
}));
const reactionsPaginator = markRaw(new Paginator('notes/reactions', {
limit: 10,
computedParams: computed(() => ({
@ -594,18 +624,6 @@ function blur() {
rootEl.value?.blur();
}
const repliesLoaded = ref(false);
function loadReplies() {
repliesLoaded.value = true;
misskeyApi('notes/children', {
noteId: appearNote.id,
limit: 30,
}).then(res => {
replies.value = res;
});
}
const conversationLoaded = ref(false);
function loadConversation() {
@ -857,21 +875,34 @@ function loadConversation() {
}
}
.reply:not(:first-child) {
.replyBorder {
border-top: solid 0.5px var(--MI_THEME-divider);
}
.tabsWrapper {
overflow-x: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.tabs {
border-top: solid 0.5px var(--MI_THEME-divider);
border-bottom: solid 0.5px var(--MI_THEME-divider);
display: flex;
min-width: max-content;
display: grid;
grid-auto-flow: column;
grid-template-columns: repeat(4, 1fr);
}
.tab {
flex: 1;
display: block;
padding: 12px 8px;
border-top: solid 2px transparent;
border-bottom: solid 2px transparent;
white-space: nowrap;
}
.tabActive {

View File

@ -4848,10 +4848,18 @@ export interface Locale extends ILocale {
*
*/
"renotes": string;
/**
*
*/
"quotes": string;
/**
*
*/
"loadReplies": string;
/**
*
*/
"loadQuotes": string;
/**
*
*/

View File

@ -2026,6 +2026,8 @@ declare namespace entities {
NotesPollsRecommendationRequest,
NotesPollsRecommendationResponse,
NotesPollsVoteRequest,
NotesQuotesRequest,
NotesQuotesResponse,
NotesReactionsRequest,
NotesReactionsResponse,
NotesReactionsCreateRequest,
@ -3141,6 +3143,12 @@ type NotesPollsRecommendationResponse = operations['notes___polls___recommendati
// @public (undocumented)
type NotesPollsVoteRequest = operations['notes___polls___vote']['requestBody']['content']['application/json'];
// @public (undocumented)
type NotesQuotesRequest = operations['notes___quotes']['requestBody']['content']['application/json'];
// @public (undocumented)
type NotesQuotesResponse = operations['notes___quotes']['responses']['200']['content']['application/json'];
// @public (undocumented)
type NotesReactionsCreateRequest = operations['notes___reactions___create']['requestBody']['content']['application/json'];

View File

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

View File

@ -521,6 +521,8 @@ import type {
NotesPollsRecommendationRequest,
NotesPollsRecommendationResponse,
NotesPollsVoteRequest,
NotesQuotesRequest,
NotesQuotesResponse,
NotesReactionsRequest,
NotesReactionsResponse,
NotesReactionsCreateRequest,
@ -1002,6 +1004,7 @@ export type Endpoints = {
'notes/mentions': { req: NotesMentionsRequest; res: NotesMentionsResponse };
'notes/polls/recommendation': { req: NotesPollsRecommendationRequest; res: NotesPollsRecommendationResponse };
'notes/polls/vote': { req: NotesPollsVoteRequest; res: EmptyResponse };
'notes/quotes': { req: NotesQuotesRequest; res: NotesQuotesResponse };
'notes/reactions': { req: NotesReactionsRequest; res: NotesReactionsResponse };
'notes/reactions/create': { req: NotesReactionsCreateRequest; res: EmptyResponse };
'notes/reactions/delete': { req: NotesReactionsDeleteRequest; res: EmptyResponse };

View File

@ -524,6 +524,8 @@ export type NotesMentionsResponse = operations['notes___mentions']['responses'][
export type NotesPollsRecommendationRequest = operations['notes___polls___recommendation']['requestBody']['content']['application/json'];
export type NotesPollsRecommendationResponse = operations['notes___polls___recommendation']['responses']['200']['content']['application/json'];
export type NotesPollsVoteRequest = operations['notes___polls___vote']['requestBody']['content']['application/json'];
export type NotesQuotesRequest = operations['notes___quotes']['requestBody']['content']['application/json'];
export type NotesQuotesResponse = operations['notes___quotes']['responses']['200']['content']['application/json'];
export type NotesReactionsRequest = operations['notes___reactions']['requestBody']['content']['application/json'];
export type NotesReactionsResponse = operations['notes___reactions']['responses']['200']['content']['application/json'];
export type NotesReactionsCreateRequest = operations['notes___reactions___create']['requestBody']['content']['application/json'];

View File

@ -3128,6 +3128,15 @@ export type paths = {
*/
post: operations['notes___polls___vote'];
};
'/notes/quotes': {
/**
* notes/quotes
* @description No description provided.
*
* **Credential required**: *No*
*/
post: operations['notes___quotes'];
};
'/notes/reactions': {
/**
* notes/reactions
@ -30416,6 +30425,80 @@ export interface operations {
};
};
};
notes___quotes: {
requestBody: {
content: {
'application/json': {
/** Format: misskey:id */
noteId: string;
/** Format: misskey:id */
sinceId?: string;
/** Format: misskey:id */
untilId?: string;
sinceDate?: number;
untilDate?: number;
/** @default 10 */
limit?: number;
};
};
};
responses: {
/** @description OK (with results) */
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Note'][];
};
};
/** @description Client error */
400: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Authentication error */
401: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Forbidden error */
403: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description I'm Ai */
418: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
notes___reactions: {
requestBody: {
content: {