Merge branch 'fix/use-pagination-in-note' into 'develop'

fix: use pagination in note

Co-authored-by: Lhcfl <Lhcfl@outlook.com>

Closes #10906

See merge request firefish/firefish!10754
This commit is contained in:
naskya 2024-04-23 14:59:40 +00:00
commit cac438b965
16 changed files with 311 additions and 229 deletions

View file

@ -14,7 +14,7 @@
}, },
"overrides": [ "overrides": [
{ {
"include": ["*.vue"], "include": ["*.vue", "packages/client/*.ts"],
"linter": { "linter": {
"rules": { "rules": {
"style": { "style": {

View file

@ -5,6 +5,13 @@ Breaking changes are indicated by the :warning: icon.
## Unreleased ## Unreleased
- Added `antennaLimit` field to the response of `meta` and `admin/meta`, and the request of `admin/update-meta` (optional). - Added `antennaLimit` field to the response of `meta` and `admin/meta`, and the request of `admin/update-meta` (optional).
- Added `filter` optional parameter to `notes/renotes` endpoint to filter the types of renotes. It can take the following values:
- `all` (default)
- `renote`
- `quote`
- :warning: Removed the following optional parameters in `notes/reactions`, as they were never taken into account due to a bug:
- `sinceId`
- `untilId`
## v20240413 ## v20240413

View file

@ -1,4 +1,4 @@
import { In } from "typeorm"; import { In, IsNull, Not } from "typeorm";
import * as mfm from "mfm-js"; import * as mfm from "mfm-js";
import { Note } from "@/models/entities/note.js"; import { Note } from "@/models/entities/note.js";
import type { User } from "@/models/entities/user.js"; import type { User } from "@/models/entities/user.js";
@ -10,6 +10,7 @@ import {
Followings, Followings,
Polls, Polls,
Channels, Channels,
Notes,
} from "../index.js"; } from "../index.js";
import type { Packed } from "@/misc/schema.js"; import type { Packed } from "@/misc/schema.js";
import { countReactions, decodeReaction, nyaify } from "backend-rs"; import { countReactions, decodeReaction, nyaify } from "backend-rs";
@ -101,7 +102,7 @@ export const NoteRepository = db.getRepository(Note).extend({
return true; return true;
} else { } else {
// 指定されているかどうか // 指定されているかどうか
return note.visibleUserIds.some((id: any) => meId === id); return note.visibleUserIds.some((id) => meId === id);
} }
} }
@ -211,8 +212,25 @@ export const NoteRepository = db.getRepository(Note).extend({
localOnly: note.localOnly || undefined, localOnly: note.localOnly || undefined,
visibleUserIds: visibleUserIds:
note.visibility === "specified" ? note.visibleUserIds : undefined, note.visibility === "specified" ? note.visibleUserIds : undefined,
// FIXME: Deleting a post does not decrease these two numbers, causing the number to be wrong
renoteCount: note.renoteCount, renoteCount: note.renoteCount,
repliesCount: note.repliesCount, repliesCount: note.repliesCount,
// TODO: add it to database and use note.quoteCount
quoteCount: Notes.count({
where: {
renoteId: note.id,
text: Not(IsNull()),
},
}),
myRenoteCount: me
? Notes.count({
where: {
renoteId: note.id,
text: IsNull(),
userId: me.id,
},
})
: undefined,
reactions: countReactions(note.reactions), reactions: countReactions(note.reactions),
reactionEmojis: reactionEmoji, reactionEmojis: reactionEmoji,
emojis: noteEmoji, emojis: noteEmoji,

View file

@ -208,5 +208,15 @@ export const packedNoteSchema = {
optional: true, optional: true,
nullable: true, nullable: true,
}, },
myRenoteCount: {
type: "number",
optional: true,
nullable: false,
},
quoteCount: {
type: "number",
optional: false,
nullable: false,
},
}, },
} as const; } as const;

View file

@ -1,6 +1,6 @@
import type { SelectQueryBuilder } from "typeorm"; import type { ObjectLiteral, SelectQueryBuilder } from "typeorm";
export function makePaginationQuery<T>( export function makePaginationQuery<T extends ObjectLiteral>(
q: SelectQueryBuilder<T>, q: SelectQueryBuilder<T>,
sinceId?: string, sinceId?: string,
untilId?: string, untilId?: string,

View file

@ -42,8 +42,6 @@ export const paramDef = {
type: { type: "string", nullable: true }, type: { type: "string", nullable: true },
limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, limit: { type: "integer", minimum: 1, maximum: 100, default: 10 },
offset: { type: "integer", default: 0 }, offset: { type: "integer", default: 0 },
sinceId: { type: "string", format: "misskey:id" },
untilId: { type: "string", format: "misskey:id" },
}, },
required: ["noteId"], required: ["noteId"],
} as const; } as const;

View file

@ -42,6 +42,12 @@ export const paramDef = {
limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, limit: { type: "integer", minimum: 1, maximum: 100, default: 10 },
sinceId: { type: "string", format: "misskey:id" }, sinceId: { type: "string", format: "misskey:id" },
untilId: { type: "string", format: "misskey:id" }, untilId: { type: "string", format: "misskey:id" },
filter: {
type: "string",
enum: ["all", "renote", "quote"],
nullable: true,
default: null,
},
}, },
required: ["noteId"], required: ["noteId"],
} as const; } as const;
@ -53,7 +59,7 @@ export default define(meta, paramDef, async (ps, user) => {
throw err; throw err;
}); });
let query = makePaginationQuery( const query = makePaginationQuery(
Notes.createQueryBuilder("note"), Notes.createQueryBuilder("note"),
ps.sinceId, ps.sinceId,
ps.untilId, ps.untilId,
@ -61,6 +67,16 @@ export default define(meta, paramDef, async (ps, user) => {
.andWhere("note.renoteId = :renoteId", { renoteId: note.id }) .andWhere("note.renoteId = :renoteId", { renoteId: note.id })
.innerJoinAndSelect("note.user", "user"); .innerJoinAndSelect("note.user", "user");
// "all" doesn't filter out anything, it's just there for
// those who prefer to set the parameter explicitly
if (ps.filter === "renote") {
query.andWhere("note.text IS NULL");
}
if (ps.filter === "quote") {
query.andWhere("note.text IS NOT NULL");
}
if (ps.userId) { if (ps.userId) {
query.andWhere("user.id = :userId", { userId: ps.userId }); query.andWhere("user.id = :userId", { userId: ps.userId });
} }

View file

@ -33,7 +33,7 @@
@contextmenu.stop="onContextmenu" @contextmenu.stop="onContextmenu"
></MkNote> ></MkNote>
<MkTab v-model="tab" :style="'underline'" @update:modelValue="loadTab"> <MkTab v-model="tab" :style="'underline'">
<option value="replies"> <option value="replies">
<!-- <i :class="icon('ph-arrow-u-up-left')"></i> --> <!-- <i :class="icon('ph-arrow-u-up-left')"></i> -->
{{ {{
@ -64,11 +64,11 @@
) )
}} }}
</option> </option>
<option v-if="directQuotes && directQuotes.length > 0" value="quotes"> <option v-if="note.quoteCount > 0" value="quotes">
<!-- <i :class="icon('ph-quotes')"></i> --> <!-- <i :class="icon('ph-quotes')"></i> -->
{{ {{
wordWithCount( wordWithCount(
directQuotes.length, note.quoteCount,
i18n.ts.quote, i18n.ts.quote,
i18n.ts.quotes, i18n.ts.quotes,
) )
@ -80,44 +80,52 @@
</option> </option>
</MkTab> </MkTab>
<MkNoteSub <MkPagination
v-for="note in directReplies" ref="repliesPagingComponent"
v-if="directReplies && tab === 'replies'" v-if="tab === 'replies' && note.repliesCount > 0"
:key="note.id" v-slot="{ items }"
:note="note" :pagination="repliesPagination"
class="reply" >
:conversation="replies" <MkNoteSub
:detailed-view="true" v-for="note in items"
:parent-id="note.id" :key="note.id"
/> :note="note"
<MkLoading v-else-if="tab === 'replies' && note.repliesCount > 0" /> class="reply"
:auto-conversation="true"
:detailed-view="true"
:parent-id="note.id"
:auto-add-replies="true"
/>
</MkPagination>
<MkNoteSub <MkPagination
v-for="note in directQuotes" v-if="tab === 'quotes'"
v-if="directQuotes && tab === 'quotes'" v-slot="{ items }"
:key="note.id" :pagination="quotePagination"
:note="note" >
class="reply" <MkNoteSub
:conversation="replies" v-for="note in items"
:detailed-view="true" :key="note.id"
:parent-id="note.id" :note="note"
/> class="reply"
<MkLoading v-else-if="tab === 'quotes' && directQuotes && directQuotes.length > 0" /> :auto-conversation="true"
:detailed-view="true"
:parent-id="note.id"
:auto-add-replies="true"
/>
</MkPagination>
<!-- <MkPagination <MkPagination
v-if="tab === 'renotes'" v-if="tab === 'renotes'"
v-slot="{ items }" v-slot="{ items }"
ref="pagingComponent" :pagination="renotePagination"
:pagination="pagination" >
> --> <MkUserCardMini
<MkUserCardMini v-for="item in items"
v-for="item in renotes" :key="item.user.id"
v-if="tab === 'renotes' && renotes" :user="item.user"
:key="item.user.id" />
:user="item.user" </MkPagination>
/>
<!-- </MkPagination> -->
<MkLoading v-else-if="tab === 'renotes' && note.renoteCount > 0" />
<div v-if="tab === 'clips' && clips.length > 0" class="_content clips"> <div v-if="tab === 'clips' && clips.length > 0" class="_content clips">
<MkA <MkA
@ -166,8 +174,8 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, onUnmounted, onUpdated, ref } from "vue"; import { onMounted, onUpdated, ref } from "vue";
import type { StreamTypes, entities } from "firefish-js"; import type { entities } from "firefish-js";
import MkTab from "@/components/MkTab.vue"; import MkTab from "@/components/MkTab.vue";
import MkNote from "@/components/MkNote.vue"; import MkNote from "@/components/MkNote.vue";
import MkNoteSub from "@/components/MkNoteSub.vue"; import MkNoteSub from "@/components/MkNoteSub.vue";
@ -185,7 +193,9 @@ import { i18n } from "@/i18n";
import { getNoteMenu } from "@/scripts/get-note-menu"; import { getNoteMenu } from "@/scripts/get-note-menu";
import { useNoteCapture } from "@/scripts/use-note-capture"; import { useNoteCapture } from "@/scripts/use-note-capture";
import { deepClone } from "@/scripts/clone"; import { deepClone } from "@/scripts/clone";
import { useStream } from "@/stream"; import MkPagination, {
type MkPaginationType,
} from "@/components/MkPagination.vue";
// import icon from "@/scripts/icon"; // import icon from "@/scripts/icon";
const props = defineProps<{ const props = defineProps<{
@ -193,8 +203,6 @@ const props = defineProps<{
pinned?: boolean; pinned?: boolean;
}>(); }>();
const stream = useStream();
const tab = ref("replies"); const tab = ref("replies");
const note = ref(deepClone(props.note)); const note = ref(deepClone(props.note));
@ -225,6 +233,10 @@ if (noteViewInterruptors.length > 0) {
}); });
} }
const repliesPagingComponent = ref<MkPaginationType<"notes/replies"> | null>(
null,
);
const el = ref<HTMLElement | null>(null); const el = ref<HTMLElement | null>(null);
const noteEl = ref(); const noteEl = ref();
const menuButton = ref<HTMLElement>(); const menuButton = ref<HTMLElement>();
@ -243,11 +255,7 @@ const muted = ref(
const translation = ref(null); const translation = ref(null);
const translating = ref(false); const translating = ref(false);
const conversation = ref<null | entities.Note[]>([]); const conversation = ref<null | entities.Note[]>([]);
const replies = ref<entities.Note[]>([]);
const directReplies = ref<null | entities.Note[]>([]);
const directQuotes = ref<null | entities.Note[]>([]);
const clips = ref(); const clips = ref();
const renotes = ref();
const isRenote = ref(note.value.renoteId != null); const isRenote = ref(note.value.renoteId != null);
let isScrolling: boolean; let isScrolling: boolean;
@ -269,6 +277,10 @@ useNoteCapture({
rootEl: el, rootEl: el,
note, note,
isDeletedRef: isDeleted, isDeletedRef: isDeleted,
onReplied: (replyNote) => {
note.value.repliesCount += 1;
repliesPagingComponent.value?.append(replyNote);
},
}); });
function reply(_viaKeyboard = false): void { function reply(_viaKeyboard = false): void {
@ -357,32 +369,6 @@ function blur() {
noteEl.value.blur(); noteEl.value.blur();
} }
directReplies.value = null;
os.api("notes/children", {
noteId: note.value.id,
limit: 30,
depth: 12,
}).then((res) => {
// biome-ignore lint/style/noParameterAssign: assign it intentially
res = res
.filter((n) => n.userId !== note.value.userId)
.reverse()
.concat(res.filter((n) => n.userId === note.value.userId));
// res = res.reduce((acc: entities.Note[], resNote) => {
// if (resNote.userId === note.value.userId) {
// return [...acc, resNote];
// }
// return [resNote, ...acc];
// }, []);
replies.value = res;
directReplies.value = res
.filter((resNote) => resNote.replyId === note.value.id)
.reverse();
directQuotes.value = res.filter(
(resNote) => resNote.renoteId === note.value.id,
);
});
conversation.value = null; conversation.value = null;
if (note.value.replyId) { if (note.value.replyId) {
os.api("notes/conversation", { os.api("notes/conversation", {
@ -401,77 +387,37 @@ os.api("notes/clips", {
clips.value = res; clips.value = res;
}); });
// const pagination = { const repliesPagination = {
// endpoint: "notes/renotes", endpoint: "notes/replies" as const,
// noteId: note.id, limit: 10,
// limit: 10, params: {
// }; noteId: note.value.id,
},
ascending: true,
};
// const pagingComponent = $ref<InstanceType<typeof MkPagination>>(); const renotePagination = {
endpoint: "notes/renotes" as const,
renotes.value = null; limit: 30,
function loadTab() { params: {
if (tab.value === "renotes" && !renotes.value) { noteId: note.value.id,
os.api("notes/renotes", { filter: "renote" as const,
noteId: note.value.id, },
limit: 100, };
}).then((res) => { const quotePagination = {
renotes.value = res; endpoint: "notes/renotes" as const,
}); limit: 30,
} params: {
} noteId: note.value.id,
filter: "quote" as const,
async function onNoteUpdated( },
noteData: StreamTypes.NoteUpdatedEvent, };
): Promise<void> {
const { type, id, body } = noteData;
let found = -1;
if (id === note.value.id) {
found = 0;
} else {
for (let i = 0; i < replies.value.length; i++) {
const reply = replies.value[i];
if (reply.id === id) {
found = i + 1;
break;
}
}
}
if (found === -1) {
return;
}
switch (type) {
case "replied": {
const { id: createdId } = body;
const replyNote = await os.api("notes/show", {
noteId: createdId,
});
replies.value.splice(found, 0, replyNote);
if (found === 0) {
directReplies.value!.push(replyNote);
}
break;
}
case "deleted":
if (found === 0) {
isDeleted.value = true;
} else {
replies.value.splice(found - 1, 1);
}
break;
}
}
document.addEventListener("wheel", () => { document.addEventListener("wheel", () => {
isScrolling = true; isScrolling = true;
}); });
onMounted(() => { onMounted(() => {
stream.on("noteUpdated", onNoteUpdated);
isScrolling = false; isScrolling = false;
noteEl.value.scrollIntoView(); noteEl.value.scrollIntoView();
}); });
@ -484,10 +430,6 @@ onUpdated(() => {
} }
} }
}); });
onUnmounted(() => {
stream.off("noteUpdated", onNoteUpdated);
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View file

@ -1,6 +1,7 @@
<template> <template>
<MkLoading v-if="conversationLoading" />
<article <article
v-if="!muted.muted || muted.what === 'reply'" v-else-if="!muted.muted || muted.what === 'reply'"
:id="detailedView ? appearNote.id : undefined" :id="detailedView ? appearNote.id : undefined"
ref="el" ref="el"
v-size="{ max: [450, 500] }" v-size="{ max: [450, 500] }"
@ -22,7 +23,7 @@
<div class="avatar-container"> <div class="avatar-container">
<MkAvatar class="avatar" :user="appearNote.user" /> <MkAvatar class="avatar" :user="appearNote.user" />
<div <div
v-if="!conversation || replies.length > 0" v-if="conversation == null || replies.length > 0"
class="line" class="line"
></div> ></div>
</div> </div>
@ -148,20 +149,32 @@
</footer> </footer>
</div> </div>
</div> </div>
<template v-if="conversation"> <MkLoading v-if="conversationLoading" />
<MkNoteSub <template v-else-if="conversation">
v-for="reply in replies" <template
v-if="replyLevel < 11 && depth < 5" v-if="replyLevel < REPLY_LEVEL_UPPERBOUND && depth < DEPTH_UPPERBOUND"
:key="reply.id" >
:note="reply" <MkNoteSub
class="reply" v-for="reply in replies.slice(0, REPLIES_LIMIT)"
:class="{ single: replies.length == 1 }" :key="reply.id"
:conversation="conversation" :note="reply"
:depth="replies.length == 1 ? depth : depth + 1" class="reply"
:reply-level="replyLevel + 1" :class="{ single: replies.length == 1 }"
:parent-id="appearNote.id" :conversation="conversation"
:detailed-view="detailedView" :depth="replies.length == 1 ? depth : depth + 1"
/> :reply-level="replyLevel + 1"
:parent-id="appearNote.id"
:detailed-view="detailedView"
:auto-add-replies="true"
/>
<div v-if="hasMoreReplies" class="more">
<div class="line"></div>
<MkA class="text _link" :to="notePage(note)"
>{{ i18n.ts.continueThread }}
<i :class="icon('ph-caret-double-right')"></i
></MkA>
</div>
</template>
<div v-else-if="replies.length > 0" class="more"> <div v-else-if="replies.length > 0" class="more">
<div class="line"></div> <div class="line"></div>
<MkA class="text _link" :to="notePage(note)" <MkA class="text _link" :to="notePage(note)"
@ -190,7 +203,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, inject, ref } from "vue"; import { computed, inject, ref, watch } from "vue";
import type { Ref } from "vue"; import type { Ref } from "vue";
import type { entities } from "firefish-js"; import type { entities } from "firefish-js";
import XNoteHeader from "@/components/MkNoteHeader.vue"; import XNoteHeader from "@/components/MkNoteHeader.vue";
@ -221,21 +234,27 @@ import type { NoteTranslation } from "@/types/note";
const router = useRouter(); const router = useRouter();
const REPLIES_LIMIT = 10;
const REPLY_LEVEL_UPPERBOUND = 11;
const DEPTH_UPPERBOUND = 5;
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
note: entities.Note; note: entities.Note;
conversation?: entities.Note[]; conversation?: entities.Note[];
parentId?; autoConversation?: boolean;
detailedView?; parentId?: string;
detailedView?: boolean;
// how many notes are in between this one and the note being viewed in detail // how many notes are in between this one and the note being viewed in detail
depth?: number; depth?: number;
// the actual reply level of this note within the conversation thread // the actual reply level of this note within the conversation thread
replyLevel?: number; replyLevel?: number;
autoAddReplies?: boolean;
}>(), }>(),
{ {
depth: 1, depth: 1,
replyLevel: 1, replyLevel: 1,
autoAddReplies: false,
}, },
); );
@ -251,6 +270,43 @@ const softMuteReasonI18nSrc = (what?: string) => {
return i18n.ts.userSaysSomething; return i18n.ts.userSaysSomething;
}; };
const conversation = ref(props.conversation);
const conversationLoading = ref(false);
const replies = ref<entities.Note[]>([]);
const hasMoreReplies = ref(false);
function updateReplies() {
replies.value = (conversation.value ?? [])
.filter(
(item) =>
item.replyId === props.note.id || item.renoteId === props.note.id,
)
.reverse();
hasMoreReplies.value = replies.value.length >= REPLIES_LIMIT + 1;
}
watch(conversation, updateReplies, {
immediate: true,
});
if (props.autoConversation) {
conversation.value = [];
if (note.value.repliesCount > 0 || note.value.renoteCount > 0) {
conversationLoading.value = true;
os.api("notes/children", {
noteId: note.value.id,
limit: REPLIES_LIMIT + 1,
depth: REPLY_LEVEL_UPPERBOUND + 1,
}).then((res) => {
conversation.value = res
.filter((n) => n.userId !== note.value.userId)
.reverse()
.concat(res.filter((n) => n.userId === note.value.userId));
conversationLoading.value = false;
});
}
}
const isRenote = const isRenote =
note.value.renote != null && note.value.renote != null &&
note.value.text == null && note.value.text == null &&
@ -277,13 +333,6 @@ const muted = ref(
); );
const translation = ref<NoteTranslation | null>(null); const translation = ref<NoteTranslation | null>(null);
const translating = ref(false); const translating = ref(false);
const replies: entities.Note[] =
props.conversation
?.filter(
(item) =>
item.replyId === props.note.id || item.renoteId === props.note.id,
)
.reverse() ?? [];
const enableEmojiReactions = defaultStore.state.enableEmojiReactions; const enableEmojiReactions = defaultStore.state.enableEmojiReactions;
const expandOnNoteClick = defaultStore.state.expandOnNoteClick; const expandOnNoteClick = defaultStore.state.expandOnNoteClick;
const lang = localStorage.getItem("lang"); const lang = localStorage.getItem("lang");
@ -329,6 +378,14 @@ useNoteCapture({
rootEl: el, rootEl: el,
note: appearNote, note: appearNote,
isDeletedRef: isDeleted, isDeletedRef: isDeleted,
onReplied: (note) => {
if (props.autoAddReplies !== true) {
return;
}
if (hasMoreReplies.value === false) {
conversation.value = (conversation.value ?? []).concat([note]);
}
},
}); });
function reply(_viaKeyboard = false): void { function reply(_viaKeyboard = false): void {

View file

@ -68,7 +68,15 @@
<script lang="ts" setup generic="E extends PagingKey"> <script lang="ts" setup generic="E extends PagingKey">
import type { ComponentPublicInstance, ComputedRef } from "vue"; import type { ComponentPublicInstance, ComputedRef } from "vue";
import { computed, isRef, onActivated, onDeactivated, ref, watch } from "vue"; import {
computed,
isRef,
onActivated,
onDeactivated,
ref,
unref,
watch,
} from "vue";
import type { Endpoints, TypeUtils } from "firefish-js"; import type { Endpoints, TypeUtils } from "firefish-js";
import * as os from "@/os"; import * as os from "@/os";
import { import {
@ -122,6 +130,12 @@ export interface Paging<E extends PagingKey = PagingKey> {
*/ */
reversed?: boolean; reversed?: boolean;
/**
* For not-reversed, not-offsetMode,
* Sort by id in ascending order
*/
ascending?: boolean;
offsetMode?: boolean; offsetMode?: boolean;
} }
@ -169,17 +183,19 @@ const init = async (): Promise<void> => {
queue.value = []; queue.value = [];
fetching.value = true; fetching.value = true;
const params = props.pagination.params const params = props.pagination.params ? unref(props.pagination.params) : {};
? isRef<Param>(props.pagination.params)
? props.pagination.params.value
: props.pagination.params
: {};
await os await os
.api(props.pagination.endpoint, { .api(props.pagination.endpoint, {
...params, ...params,
limit: props.pagination.noPaging limit: props.pagination.noPaging
? props.pagination.limit || 10 ? props.pagination.limit || 10
: (props.pagination.limit || 10) + 1, : (props.pagination.limit || 10) + 1,
...(props.pagination.ascending
? {
// An initial value smaller than all possible ids must be filled in here.
sinceId: "0",
}
: {}),
}) })
.then( .then(
(res: Item[]) => { (res: Item[]) => {
@ -196,10 +212,10 @@ const init = async (): Promise<void> => {
res.length > (props.pagination.limit || 10) res.length > (props.pagination.limit || 10)
) { ) {
res.pop(); res.pop();
items.value = props.pagination.reversed ? [...res].reverse() : res; items.value = props.pagination.reversed ? res.toReversed() : res;
more.value = true; more.value = true;
} else { } else {
items.value = props.pagination.reversed ? [...res].reverse() : res; items.value = props.pagination.reversed ? res.toReversed() : res;
more.value = false; more.value = false;
} }
offset.value = res.length; offset.value = res.length;
@ -219,11 +235,7 @@ const reload = (): Promise<void> => {
}; };
const refresh = async (): Promise<void> => { const refresh = async (): Promise<void> => {
const params = props.pagination.params const params = props.pagination.params ? unref(props.pagination.params) : {};
? isRef<Param>(props.pagination.params)
? props.pagination.params.value
: props.pagination.params
: {};
await os await os
.api(props.pagination.endpoint, { .api(props.pagination.endpoint, {
...params, ...params,
@ -269,11 +281,7 @@ const fetchMore = async (): Promise<void> => {
return; return;
moreFetching.value = true; moreFetching.value = true;
backed.value = true; backed.value = true;
const params = props.pagination.params const params = props.pagination.params ? unref(props.pagination.params) : {};
? isRef<Param>(props.pagination.params)
? props.pagination.params.value
: props.pagination.params
: {};
await os await os
.api(props.pagination.endpoint, { .api(props.pagination.endpoint, {
...params, ...params,
@ -286,9 +294,13 @@ const fetchMore = async (): Promise<void> => {
? { ? {
sinceId: items.value[0].id, sinceId: items.value[0].id,
} }
: { : props.pagination.ascending
untilId: items.value[items.value.length - 1].id, ? {
}), sinceId: items.value[items.value.length - 1].id,
}
: {
untilId: items.value[items.value.length - 1].id,
}),
}) })
.then( .then(
(res: Item[]) => { (res: Item[]) => {
@ -303,12 +315,12 @@ const fetchMore = async (): Promise<void> => {
if (res.length > SECOND_FETCH_LIMIT) { if (res.length > SECOND_FETCH_LIMIT) {
res.pop(); res.pop();
items.value = props.pagination.reversed items.value = props.pagination.reversed
? [...res].reverse().concat(items.value) ? res.toReversed().concat(items.value)
: items.value.concat(res); : items.value.concat(res);
more.value = true; more.value = true;
} else { } else {
items.value = props.pagination.reversed items.value = props.pagination.reversed
? [...res].reverse().concat(items.value) ? res.toReversed().concat(items.value)
: items.value.concat(res); : items.value.concat(res);
more.value = false; more.value = false;
} }
@ -330,11 +342,7 @@ const fetchMoreAhead = async (): Promise<void> => {
) )
return; return;
moreFetching.value = true; moreFetching.value = true;
const params = props.pagination.params const params = props.pagination.params ? unref(props.pagination.params) : {};
? isRef<Param>(props.pagination.params)
? props.pagination.params.value
: props.pagination.params
: {};
await os await os
.api(props.pagination.endpoint, { .api(props.pagination.endpoint, {
...params, ...params,
@ -356,12 +364,12 @@ const fetchMoreAhead = async (): Promise<void> => {
if (res.length > SECOND_FETCH_LIMIT) { if (res.length > SECOND_FETCH_LIMIT) {
res.pop(); res.pop();
items.value = props.pagination.reversed items.value = props.pagination.reversed
? [...res].reverse().concat(items.value) ? res.toReversed().concat(items.value)
: items.value.concat(res); : items.value.concat(res);
more.value = true; more.value = true;
} else { } else {
items.value = props.pagination.reversed items.value = props.pagination.reversed
? [...res].reverse().concat(items.value) ? res.toReversed().concat(items.value)
: items.value.concat(res); : items.value.concat(res);
more.value = false; more.value = false;
} }

View file

@ -23,7 +23,13 @@
}}</span> }}</span>
</button> </button>
</div> </div>
<MkUserCardMini v-for="user in users" :key="user.id" :user="user" /> <MkPagination
ref="pagingComponent"
:pagination="pagination"
v-slot="{ items }"
>
<MkUserCardMini v-for="{ user: user } in items" :key="user.id" :user="user" />
</MkPagination>
</div> </div>
<div v-else> <div v-else>
<MkLoading /> <MkLoading />
@ -36,6 +42,9 @@ import type { entities } from "firefish-js";
import MkReactionIcon from "@/components/MkReactionIcon.vue"; import MkReactionIcon from "@/components/MkReactionIcon.vue";
import MkUserCardMini from "@/components/MkUserCardMini.vue"; import MkUserCardMini from "@/components/MkUserCardMini.vue";
import * as os from "@/os"; import * as os from "@/os";
import MkPagination, {
type MkPaginationType,
} from "@/components/MkPagination.vue";
const props = defineProps<{ const props = defineProps<{
noteId: entities.Note["id"]; noteId: entities.Note["id"];
@ -44,16 +53,22 @@ const props = defineProps<{
const note = ref<entities.Note>(); const note = ref<entities.Note>();
const tab = ref<string | null>(null); const tab = ref<string | null>(null);
const reactions = ref<string[]>(); const reactions = ref<string[]>();
const users = ref();
async function updateUsers(): void { const pagingComponent = ref<MkPaginationType<"notes/reactions"> | null>(null);
const res = await os.api("notes/reactions", {
const pagination = {
endpoint: "notes/reactions" as const,
params: {
noteId: props.noteId, noteId: props.noteId,
type: tab.value, type: tab.value,
limit: 30, },
}); offsetMode: true,
limit: 30,
};
users.value = res.map((x) => x.user); function updateUsers(): void {
pagination.params.type = tab.value;
pagingComponent.value?.reload();
} }
watch(tab, updateUsers); watch(tab, updateUsers);
@ -64,7 +79,7 @@ onMounted(() => {
}).then(async (res) => { }).then(async (res) => {
reactions.value = Object.keys(res.reactions); reactions.value = Object.keys(res.reactions);
note.value = res; note.value = res;
await updateUsers(); // updateUsers();
}); });
}); });
</script> </script>

View file

@ -27,7 +27,7 @@ import Ripple from "@/components/MkRipple.vue";
import XDetails from "@/components/MkUsersTooltip.vue"; import XDetails from "@/components/MkUsersTooltip.vue";
import { pleaseLogin } from "@/scripts/please-login"; import { pleaseLogin } from "@/scripts/please-login";
import * as os from "@/os"; import * as os from "@/os";
import { isSignedIn, me } from "@/me"; import { me } from "@/me";
import { useTooltip } from "@/scripts/use-tooltip"; import { useTooltip } from "@/scripts/use-tooltip";
import { i18n } from "@/i18n"; import { i18n } from "@/i18n";
import { defaultStore } from "@/store"; import { defaultStore } from "@/store";
@ -72,17 +72,9 @@ useTooltip(buttonRef, async (showing) => {
); );
}); });
const hasRenotedBefore = ref(false); const hasRenotedBefore = ref(
props.note.myRenoteCount && props.note.myRenoteCount > 0,
if (isSignedIn(me)) { );
os.api("notes/renotes", {
noteId: props.note.id,
userId: me.id,
limit: 1,
}).then((res) => {
hasRenotedBefore.value = res.length > 0;
});
}
const renote = (viaKeyboard = false, ev?: MouseEvent) => { const renote = (viaKeyboard = false, ev?: MouseEvent) => {
pleaseLogin(); pleaseLogin();

View file

@ -1,7 +1,7 @@
<template> <template>
<p v-if="note.cw != null" class="cw"> <p v-if="note.cw != null" class="cw">
<MkA <MkA
v-if="conversation && note.renoteId == parentId" v-if="conversation && note.renoteId == parentId && parentId != null"
:to=" :to="
detailedView ? `#${parentId}` : `${notePage(note)}#${parentId}` detailedView ? `#${parentId}` : `${notePage(note)}#${parentId}`
" "
@ -198,8 +198,8 @@ import icon from "@/scripts/icon";
const props = defineProps<{ const props = defineProps<{
note: entities.Note; note: entities.Note;
parentId?; parentId?: string;
conversation?; conversation?: entities.Note[];
detailed?: boolean; detailed?: boolean;
detailedView?: boolean; detailedView?: boolean;
}>(); }>();

View file

@ -9,6 +9,7 @@ export function useNoteCapture(props: {
rootEl: Ref<HTMLElement | null>; rootEl: Ref<HTMLElement | null>;
note: Ref<entities.Note>; note: Ref<entities.Note>;
isDeletedRef: Ref<boolean>; isDeletedRef: Ref<boolean>;
onReplied?: (note: entities.Note) => void;
}) { }) {
const note = props.note; const note = props.note;
const connection = isSignedIn(me) ? useStream() : null; const connection = isSignedIn(me) ? useStream() : null;
@ -19,6 +20,16 @@ export function useNoteCapture(props: {
if (id !== note.value.id) return; if (id !== note.value.id) return;
switch (type) { switch (type) {
case "replied": {
if (props.onReplied) {
const { id: createdId } = body;
const replyNote = await os.api("notes/show", {
noteId: createdId,
});
props.onReplied(replyNote);
}
break;
}
case "reacted": { case "reacted": {
const reaction = body.reaction; const reaction = body.reaction;

View file

@ -773,7 +773,12 @@ export type Endpoints = {
res: null; res: null;
}; };
"notes/reactions": { "notes/reactions": {
req: { noteId: Note["id"]; type?: string | null; limit?: number }; req: {
noteId: Note["id"];
type?: string | null;
limit?: number;
offset?: number;
};
res: NoteReaction[]; res: NoteReaction[];
}; };
"notes/reactions/create": { "notes/reactions/create": {
@ -787,6 +792,7 @@ export type Endpoints = {
sinceId?: Note["id"]; sinceId?: Note["id"];
untilId?: Note["id"]; untilId?: Note["id"];
noteId: Note["id"]; noteId: Note["id"];
filter?: "all" | "renote" | "quote";
}; };
res: Note[]; res: Note[];
}; };

View file

@ -174,9 +174,11 @@ export type Note = {
channelId?: Channel["id"]; channelId?: Channel["id"];
channel?: Channel; channel?: Channel;
myReaction?: string; myReaction?: string;
myRenoteCount?: number;
reactions: Record<string, number>; reactions: Record<string, number>;
renoteCount: number; renoteCount: number;
repliesCount: number; repliesCount: number;
quoteCount: number;
poll?: { poll?: {
expiresAt: DateString | null; expiresAt: DateString | null;
multiple: boolean; multiple: boolean;