diff --git a/biome.json b/biome.json index 21b711f457..487165266a 100644 --- a/biome.json +++ b/biome.json @@ -14,7 +14,7 @@ }, "overrides": [ { - "include": ["*.vue"], + "include": ["*.vue", "packages/client/*.ts"], "linter": { "rules": { "style": { diff --git a/docs/api-change.md b/docs/api-change.md index dcd4329a27..89cf8a9d38 100644 --- a/docs/api-change.md +++ b/docs/api-change.md @@ -5,6 +5,13 @@ Breaking changes are indicated by the :warning: icon. ## Unreleased - 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 diff --git a/packages/backend-rs/index.d.ts b/packages/backend-rs/index.d.ts index 69de833dcc..dec480d79c 100644 --- a/packages/backend-rs/index.d.ts +++ b/packages/backend-rs/index.d.ts @@ -193,6 +193,7 @@ export interface Acct { } export function stringToAcct(acct: string): Acct export function acctToString(acct: Acct): string +export function addNoteToAntenna(antennaId: string, note: Note): void /** * @param host punycoded instance host * @returns whether the given host should be blocked @@ -1119,7 +1120,6 @@ export interface Webhook { latestSentAt: Date | null latestStatus: number | null } -export function addNoteToAntenna(antennaId: string, note: Note): void /** Initializes Cuid2 generator. Must be called before any [create_id]. */ export function initIdGenerator(length: number, fingerprint: string): void export function getTimestamp(id: string): number diff --git a/packages/backend-rs/index.js b/packages/backend-rs/index.js index b4d86dccdf..d801e28fce 100644 --- a/packages/backend-rs/index.js +++ b/packages/backend-rs/index.js @@ -310,12 +310,13 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { loadEnv, loadConfig, stringToAcct, acctToString, isBlockedServer, isSilencedServer, isAllowedServer, checkWordMute, getFullApAccount, isSelfHost, isSameOrigin, extractHost, toPuny, isUnicodeEmoji, sqlLikeEscape, safeForSql, formatMilliseconds, getNoteSummary, toMastodonId, fromMastodonId, fetchMeta, metaToPugArgs, nyaify, hashPassword, verifyPassword, isOldPasswordAlgorithm, decodeReaction, countReactions, toDbReaction, AntennaSrcEnum, DriveFileUsageHintEnum, MutedNoteReasonEnum, NoteVisibilityEnum, NotificationTypeEnum, PageVisibilityEnum, PollNotevisibilityEnum, RelayStatusEnum, UserEmojimodpermEnum, UserProfileFfvisibilityEnum, UserProfileMutingnotificationtypesEnum, addNoteToAntenna, initIdGenerator, getTimestamp, genId, secureRndstr } = nativeBinding +const { loadEnv, loadConfig, stringToAcct, acctToString, addNoteToAntenna, isBlockedServer, isSilencedServer, isAllowedServer, checkWordMute, getFullApAccount, isSelfHost, isSameOrigin, extractHost, toPuny, isUnicodeEmoji, sqlLikeEscape, safeForSql, formatMilliseconds, getNoteSummary, toMastodonId, fromMastodonId, fetchMeta, metaToPugArgs, nyaify, hashPassword, verifyPassword, isOldPasswordAlgorithm, decodeReaction, countReactions, toDbReaction, AntennaSrcEnum, DriveFileUsageHintEnum, MutedNoteReasonEnum, NoteVisibilityEnum, NotificationTypeEnum, PageVisibilityEnum, PollNotevisibilityEnum, RelayStatusEnum, UserEmojimodpermEnum, UserProfileFfvisibilityEnum, UserProfileMutingnotificationtypesEnum, initIdGenerator, getTimestamp, genId, secureRndstr } = nativeBinding module.exports.loadEnv = loadEnv module.exports.loadConfig = loadConfig module.exports.stringToAcct = stringToAcct module.exports.acctToString = acctToString +module.exports.addNoteToAntenna = addNoteToAntenna module.exports.isBlockedServer = isBlockedServer module.exports.isSilencedServer = isSilencedServer module.exports.isAllowedServer = isAllowedServer @@ -352,7 +353,6 @@ module.exports.RelayStatusEnum = RelayStatusEnum module.exports.UserEmojimodpermEnum = UserEmojimodpermEnum module.exports.UserProfileFfvisibilityEnum = UserProfileFfvisibilityEnum module.exports.UserProfileMutingnotificationtypesEnum = UserProfileMutingnotificationtypesEnum -module.exports.addNoteToAntenna = addNoteToAntenna module.exports.initIdGenerator = initIdGenerator module.exports.getTimestamp = getTimestamp module.exports.genId = genId diff --git a/packages/backend-rs/src/service/add_note_to_antenna.rs b/packages/backend-rs/src/misc/add_note_to_antenna.rs similarity index 69% rename from packages/backend-rs/src/service/add_note_to_antenna.rs rename to packages/backend-rs/src/misc/add_note_to_antenna.rs index 4f294cc881..2ed698d7e6 100644 --- a/packages/backend-rs/src/service/add_note_to_antenna.rs +++ b/packages/backend-rs/src/misc/add_note_to_antenna.rs @@ -1,13 +1,14 @@ use crate::database::{redis_conn, redis_key}; use crate::model::entity::note; -use crate::service::stream::{publish_to_stream, Error, Stream}; +use crate::service::stream; use crate::util::id::get_timestamp; use redis::{streams::StreamMaxlen, Commands}; type Note = note::Model; #[crate::export] -pub fn add_note_to_antenna(antenna_id: String, note: &Note) -> Result<(), Error> { +pub fn add_note_to_antenna(antenna_id: String, note: &Note) -> Result<(), stream::Error> { + // for timeline API redis_conn()?.xadd_maxlen( redis_key(format!("antennaTimeline:{}", antenna_id)), StreamMaxlen::Approx(200), @@ -15,9 +16,6 @@ pub fn add_note_to_antenna(antenna_id: String, note: &Note) -> Result<(), Error> &[("note", ¬e.id)], )?; - publish_to_stream( - &Stream::Antenna { antenna_id }, - Some("note"), - Some(serde_json::to_string(note)?), - ) + // for streaming API + stream::antenna::publish(antenna_id, note) } diff --git a/packages/backend-rs/src/misc/mod.rs b/packages/backend-rs/src/misc/mod.rs index 24cec14969..8246b6a31a 100644 --- a/packages/backend-rs/src/misc/mod.rs +++ b/packages/backend-rs/src/misc/mod.rs @@ -1,4 +1,5 @@ pub mod acct; +pub mod add_note_to_antenna; pub mod check_server_block; pub mod check_word_mute; pub mod convert_host; diff --git a/packages/backend-rs/src/service/mod.rs b/packages/backend-rs/src/service/mod.rs index cc239e3f9e..baf29e06ad 100644 --- a/packages/backend-rs/src/service/mod.rs +++ b/packages/backend-rs/src/service/mod.rs @@ -1,2 +1 @@ -pub mod add_note_to_antenna; pub mod stream; diff --git a/packages/backend-rs/src/service/stream.rs b/packages/backend-rs/src/service/stream.rs index 6c5e6be4dd..1e84d5f866 100644 --- a/packages/backend-rs/src/service/stream.rs +++ b/packages/backend-rs/src/service/stream.rs @@ -1,3 +1,5 @@ +pub mod antenna; + use crate::config::CONFIG; use crate::database::redis_conn; use redis::{Commands, RedisError}; diff --git a/packages/backend-rs/src/service/stream/antenna.rs b/packages/backend-rs/src/service/stream/antenna.rs new file mode 100644 index 0000000000..08ae391caf --- /dev/null +++ b/packages/backend-rs/src/service/stream/antenna.rs @@ -0,0 +1,10 @@ +use crate::model::entity::note; +use crate::service::stream::{publish_to_stream, Error, Stream}; + +pub fn publish(antenna_id: String, note: ¬e::Model) -> Result<(), Error> { + publish_to_stream( + &Stream::Antenna { antenna_id }, + Some("note"), + Some(serde_json::to_string(note)?), + ) +} diff --git a/packages/backend/src/models/repositories/note.ts b/packages/backend/src/models/repositories/note.ts index c877048709..7fa26373b8 100644 --- a/packages/backend/src/models/repositories/note.ts +++ b/packages/backend/src/models/repositories/note.ts @@ -1,4 +1,4 @@ -import { In } from "typeorm"; +import { In, IsNull, Not } from "typeorm"; import * as mfm from "mfm-js"; import { Note } from "@/models/entities/note.js"; import type { User } from "@/models/entities/user.js"; @@ -10,6 +10,7 @@ import { Followings, Polls, Channels, + Notes, } from "../index.js"; import type { Packed } from "@/misc/schema.js"; import { countReactions, decodeReaction, nyaify } from "backend-rs"; @@ -101,7 +102,7 @@ export const NoteRepository = db.getRepository(Note).extend({ return true; } 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, visibleUserIds: 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, 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), reactionEmojis: reactionEmoji, emojis: noteEmoji, diff --git a/packages/backend/src/models/schema/note.ts b/packages/backend/src/models/schema/note.ts index fff872b69f..6064919960 100644 --- a/packages/backend/src/models/schema/note.ts +++ b/packages/backend/src/models/schema/note.ts @@ -208,5 +208,15 @@ export const packedNoteSchema = { optional: true, nullable: true, }, + myRenoteCount: { + type: "number", + optional: true, + nullable: false, + }, + quoteCount: { + type: "number", + optional: false, + nullable: false, + }, }, } as const; diff --git a/packages/backend/src/server/api/common/make-pagination-query.ts b/packages/backend/src/server/api/common/make-pagination-query.ts index a2c3275693..83827b3df1 100644 --- a/packages/backend/src/server/api/common/make-pagination-query.ts +++ b/packages/backend/src/server/api/common/make-pagination-query.ts @@ -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>, sinceId?: string, untilId?: string, diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 4f65c59a9e..9a2b49cb3d 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -13,7 +13,7 @@ import { normalizeForSearch } from "@/misc/normalize-for-search.js"; import { verifyLink } from "@/services/fetch-rel-me.js"; import { ApiError } from "@/server/api/error.js"; import define from "@/server/api/define.js"; -import { DriveFile } from "@/models/entities/drive-file"; +import type { DriveFile } from "@/models/entities/drive-file"; export const meta = { tags: ["account"], diff --git a/packages/backend/src/server/api/endpoints/notes/reactions.ts b/packages/backend/src/server/api/endpoints/notes/reactions.ts index 386a3a08df..d6ff8888af 100644 --- a/packages/backend/src/server/api/endpoints/notes/reactions.ts +++ b/packages/backend/src/server/api/endpoints/notes/reactions.ts @@ -42,8 +42,6 @@ export const paramDef = { type: { type: "string", nullable: true }, limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, offset: { type: "integer", default: 0 }, - sinceId: { type: "string", format: "misskey:id" }, - untilId: { type: "string", format: "misskey:id" }, }, required: ["noteId"], } as const; diff --git a/packages/backend/src/server/api/endpoints/notes/renotes.ts b/packages/backend/src/server/api/endpoints/notes/renotes.ts index 683004ebe8..16304dd269 100644 --- a/packages/backend/src/server/api/endpoints/notes/renotes.ts +++ b/packages/backend/src/server/api/endpoints/notes/renotes.ts @@ -42,6 +42,12 @@ export const paramDef = { limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, sinceId: { type: "string", format: "misskey:id" }, untilId: { type: "string", format: "misskey:id" }, + filter: { + type: "string", + enum: ["all", "renote", "quote"], + nullable: true, + default: null, + }, }, required: ["noteId"], } as const; @@ -53,7 +59,7 @@ export default define(meta, paramDef, async (ps, user) => { throw err; }); - let query = makePaginationQuery( + const query = makePaginationQuery( Notes.createQueryBuilder("note"), ps.sinceId, ps.untilId, @@ -61,6 +67,16 @@ export default define(meta, paramDef, async (ps, user) => { .andWhere("note.renoteId = :renoteId", { renoteId: note.id }) .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) { query.andWhere("user.id = :userId", { userId: ps.userId }); } diff --git a/packages/client/src/components/MkNoteDetailed.vue b/packages/client/src/components/MkNoteDetailed.vue index b6c0d784ba..2c242645b4 100644 --- a/packages/client/src/components/MkNoteDetailed.vue +++ b/packages/client/src/components/MkNoteDetailed.vue @@ -33,7 +33,7 @@ @contextmenu.stop="onContextmenu" ></MkNote> - <MkTab v-model="tab" :style="'underline'" @update:modelValue="loadTab"> + <MkTab v-model="tab" :style="'underline'"> <option value="replies"> <!-- <i :class="icon('ph-arrow-u-up-left')"></i> --> {{ @@ -64,11 +64,11 @@ ) }} </option> - <option v-if="directQuotes && directQuotes.length > 0" value="quotes"> + <option v-if="note.quoteCount > 0" value="quotes"> <!-- <i :class="icon('ph-quotes')"></i> --> {{ wordWithCount( - directQuotes.length, + note.quoteCount, i18n.ts.quote, i18n.ts.quotes, ) @@ -80,44 +80,52 @@ </option> </MkTab> - <MkNoteSub - v-for="note in directReplies" - v-if="directReplies && tab === 'replies'" - :key="note.id" - :note="note" - class="reply" - :conversation="replies" - :detailed-view="true" - :parent-id="note.id" - /> - <MkLoading v-else-if="tab === 'replies' && note.repliesCount > 0" /> + <MkPagination + ref="repliesPagingComponent" + v-if="tab === 'replies' && note.repliesCount > 0" + v-slot="{ items }" + :pagination="repliesPagination" + > + <MkNoteSub + v-for="note in items" + :key="note.id" + :note="note" + class="reply" + :auto-conversation="true" + :detailed-view="true" + :parent-id="note.id" + :auto-add-replies="true" + /> + </MkPagination> - <MkNoteSub - v-for="note in directQuotes" - v-if="directQuotes && tab === 'quotes'" - :key="note.id" - :note="note" - class="reply" - :conversation="replies" - :detailed-view="true" - :parent-id="note.id" - /> - <MkLoading v-else-if="tab === 'quotes' && directQuotes && directQuotes.length > 0" /> + <MkPagination + v-if="tab === 'quotes'" + v-slot="{ items }" + :pagination="quotePagination" + > + <MkNoteSub + v-for="note in items" + :key="note.id" + :note="note" + class="reply" + :auto-conversation="true" + :detailed-view="true" + :parent-id="note.id" + :auto-add-replies="true" + /> + </MkPagination> - <!-- <MkPagination + <MkPagination v-if="tab === 'renotes'" v-slot="{ items }" - ref="pagingComponent" - :pagination="pagination" - > --> - <MkUserCardMini - v-for="item in renotes" - v-if="tab === 'renotes' && renotes" - :key="item.user.id" - :user="item.user" - /> - <!-- </MkPagination> --> - <MkLoading v-else-if="tab === 'renotes' && note.renoteCount > 0" /> + :pagination="renotePagination" + > + <MkUserCardMini + v-for="item in items" + :key="item.user.id" + :user="item.user" + /> + </MkPagination> <div v-if="tab === 'clips' && clips.length > 0" class="_content clips"> <MkA @@ -166,8 +174,8 @@ </template> <script lang="ts" setup> -import { onMounted, onUnmounted, onUpdated, ref } from "vue"; -import type { StreamTypes, entities } from "firefish-js"; +import { onMounted, onUpdated, ref } from "vue"; +import type { entities } from "firefish-js"; import MkTab from "@/components/MkTab.vue"; import MkNote from "@/components/MkNote.vue"; import MkNoteSub from "@/components/MkNoteSub.vue"; @@ -185,7 +193,9 @@ import { i18n } from "@/i18n"; import { getNoteMenu } from "@/scripts/get-note-menu"; import { useNoteCapture } from "@/scripts/use-note-capture"; import { deepClone } from "@/scripts/clone"; -import { useStream } from "@/stream"; +import MkPagination, { + type MkPaginationType, +} from "@/components/MkPagination.vue"; // import icon from "@/scripts/icon"; const props = defineProps<{ @@ -193,8 +203,6 @@ const props = defineProps<{ pinned?: boolean; }>(); -const stream = useStream(); - const tab = ref("replies"); 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 noteEl = ref(); const menuButton = ref<HTMLElement>(); @@ -243,11 +255,7 @@ const muted = ref( const translation = ref(null); const translating = ref(false); 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 renotes = ref(); const isRenote = ref(note.value.renoteId != null); let isScrolling: boolean; @@ -269,6 +277,10 @@ useNoteCapture({ rootEl: el, note, isDeletedRef: isDeleted, + onReplied: (replyNote) => { + note.value.repliesCount += 1; + repliesPagingComponent.value?.append(replyNote); + }, }); function reply(_viaKeyboard = false): void { @@ -357,32 +369,6 @@ function 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; if (note.value.replyId) { os.api("notes/conversation", { @@ -401,77 +387,37 @@ os.api("notes/clips", { clips.value = res; }); -// const pagination = { -// endpoint: "notes/renotes", -// noteId: note.id, -// limit: 10, -// }; +const repliesPagination = { + endpoint: "notes/replies" as const, + limit: 10, + params: { + noteId: note.value.id, + }, + ascending: true, +}; -// const pagingComponent = $ref<InstanceType<typeof MkPagination>>(); - -renotes.value = null; -function loadTab() { - if (tab.value === "renotes" && !renotes.value) { - os.api("notes/renotes", { - noteId: note.value.id, - limit: 100, - }).then((res) => { - renotes.value = res; - }); - } -} - -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; - } -} +const renotePagination = { + endpoint: "notes/renotes" as const, + limit: 30, + params: { + noteId: note.value.id, + filter: "renote" as const, + }, +}; +const quotePagination = { + endpoint: "notes/renotes" as const, + limit: 30, + params: { + noteId: note.value.id, + filter: "quote" as const, + }, +}; document.addEventListener("wheel", () => { isScrolling = true; }); onMounted(() => { - stream.on("noteUpdated", onNoteUpdated); isScrolling = false; noteEl.value.scrollIntoView(); }); @@ -484,10 +430,6 @@ onUpdated(() => { } } }); - -onUnmounted(() => { - stream.off("noteUpdated", onNoteUpdated); -}); </script> <style lang="scss" scoped> diff --git a/packages/client/src/components/MkNoteSub.vue b/packages/client/src/components/MkNoteSub.vue index aed6de486d..b063a7dd15 100644 --- a/packages/client/src/components/MkNoteSub.vue +++ b/packages/client/src/components/MkNoteSub.vue @@ -1,6 +1,7 @@ <template> + <MkLoading v-if="conversationLoading" /> <article - v-if="!muted.muted || muted.what === 'reply'" + v-else-if="!muted.muted || muted.what === 'reply'" :id="detailedView ? appearNote.id : undefined" ref="el" v-size="{ max: [450, 500] }" @@ -22,7 +23,7 @@ <div class="avatar-container"> <MkAvatar class="avatar" :user="appearNote.user" /> <div - v-if="!conversation || replies.length > 0" + v-if="conversation == null || replies.length > 0" class="line" ></div> </div> @@ -148,20 +149,32 @@ </footer> </div> </div> - <template v-if="conversation"> - <MkNoteSub - v-for="reply in replies" - v-if="replyLevel < 11 && depth < 5" - :key="reply.id" - :note="reply" - class="reply" - :class="{ single: replies.length == 1 }" - :conversation="conversation" - :depth="replies.length == 1 ? depth : depth + 1" - :reply-level="replyLevel + 1" - :parent-id="appearNote.id" - :detailed-view="detailedView" - /> + <MkLoading v-if="conversationLoading" /> + <template v-else-if="conversation"> + <template + v-if="replyLevel < REPLY_LEVEL_UPPERBOUND && depth < DEPTH_UPPERBOUND" + > + <MkNoteSub + v-for="reply in replies.slice(0, REPLIES_LIMIT)" + :key="reply.id" + :note="reply" + class="reply" + :class="{ single: replies.length == 1 }" + :conversation="conversation" + :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 class="line"></div> <MkA class="text _link" :to="notePage(note)" @@ -190,7 +203,7 @@ </template> <script lang="ts" setup> -import { computed, inject, ref } from "vue"; +import { computed, inject, ref, watch } from "vue"; import type { Ref } from "vue"; import type { entities } from "firefish-js"; import XNoteHeader from "@/components/MkNoteHeader.vue"; @@ -221,21 +234,27 @@ import type { NoteTranslation } from "@/types/note"; const router = useRouter(); +const REPLIES_LIMIT = 10; +const REPLY_LEVEL_UPPERBOUND = 11; +const DEPTH_UPPERBOUND = 5; + const props = withDefaults( defineProps<{ note: entities.Note; conversation?: entities.Note[]; - parentId?; - detailedView?; - + autoConversation?: boolean; + parentId?: string; + detailedView?: boolean; // how many notes are in between this one and the note being viewed in detail depth?: number; // the actual reply level of this note within the conversation thread replyLevel?: number; + autoAddReplies?: boolean; }>(), { depth: 1, replyLevel: 1, + autoAddReplies: false, }, ); @@ -251,6 +270,43 @@ const softMuteReasonI18nSrc = (what?: string) => { 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 = note.value.renote != null && note.value.text == null && @@ -277,13 +333,6 @@ const muted = ref( ); const translation = ref<NoteTranslation | null>(null); 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 expandOnNoteClick = defaultStore.state.expandOnNoteClick; const lang = localStorage.getItem("lang"); @@ -329,6 +378,14 @@ useNoteCapture({ rootEl: el, note: appearNote, isDeletedRef: isDeleted, + onReplied: (note) => { + if (props.autoAddReplies !== true) { + return; + } + if (hasMoreReplies.value === false) { + conversation.value = (conversation.value ?? []).concat([note]); + } + }, }); function reply(_viaKeyboard = false): void { diff --git a/packages/client/src/components/MkPagination.vue b/packages/client/src/components/MkPagination.vue index 03a1f0e35f..2f092cb8ad 100644 --- a/packages/client/src/components/MkPagination.vue +++ b/packages/client/src/components/MkPagination.vue @@ -68,7 +68,15 @@ <script lang="ts" setup generic="E extends PagingKey"> 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 * as os from "@/os"; import { @@ -122,6 +130,12 @@ export interface Paging<E extends PagingKey = PagingKey> { */ reversed?: boolean; + /** + * For not-reversed, not-offsetMode, + * Sort by id in ascending order + */ + ascending?: boolean; + offsetMode?: boolean; } @@ -169,17 +183,19 @@ const init = async (): Promise<void> => { queue.value = []; fetching.value = true; - const params = props.pagination.params - ? isRef<Param>(props.pagination.params) - ? props.pagination.params.value - : props.pagination.params - : {}; + const params = props.pagination.params ? unref(props.pagination.params) : {}; await os .api(props.pagination.endpoint, { ...params, limit: props.pagination.noPaging ? props.pagination.limit || 10 : (props.pagination.limit || 10) + 1, + ...(props.pagination.ascending + ? { + // An initial value smaller than all possible ids must be filled in here. + sinceId: "0", + } + : {}), }) .then( (res: Item[]) => { @@ -196,10 +212,10 @@ const init = async (): Promise<void> => { res.length > (props.pagination.limit || 10) ) { res.pop(); - items.value = props.pagination.reversed ? [...res].reverse() : res; + items.value = props.pagination.reversed ? res.toReversed() : res; more.value = true; } else { - items.value = props.pagination.reversed ? [...res].reverse() : res; + items.value = props.pagination.reversed ? res.toReversed() : res; more.value = false; } offset.value = res.length; @@ -219,11 +235,7 @@ const reload = (): Promise<void> => { }; const refresh = async (): Promise<void> => { - const params = props.pagination.params - ? isRef<Param>(props.pagination.params) - ? props.pagination.params.value - : props.pagination.params - : {}; + const params = props.pagination.params ? unref(props.pagination.params) : {}; await os .api(props.pagination.endpoint, { ...params, @@ -269,11 +281,7 @@ const fetchMore = async (): Promise<void> => { return; moreFetching.value = true; backed.value = true; - const params = props.pagination.params - ? isRef<Param>(props.pagination.params) - ? props.pagination.params.value - : props.pagination.params - : {}; + const params = props.pagination.params ? unref(props.pagination.params) : {}; await os .api(props.pagination.endpoint, { ...params, @@ -286,9 +294,13 @@ const fetchMore = async (): Promise<void> => { ? { sinceId: items.value[0].id, } - : { - untilId: items.value[items.value.length - 1].id, - }), + : props.pagination.ascending + ? { + sinceId: items.value[items.value.length - 1].id, + } + : { + untilId: items.value[items.value.length - 1].id, + }), }) .then( (res: Item[]) => { @@ -303,12 +315,12 @@ const fetchMore = async (): Promise<void> => { if (res.length > SECOND_FETCH_LIMIT) { res.pop(); items.value = props.pagination.reversed - ? [...res].reverse().concat(items.value) + ? res.toReversed().concat(items.value) : items.value.concat(res); more.value = true; } else { items.value = props.pagination.reversed - ? [...res].reverse().concat(items.value) + ? res.toReversed().concat(items.value) : items.value.concat(res); more.value = false; } @@ -330,11 +342,7 @@ const fetchMoreAhead = async (): Promise<void> => { ) return; moreFetching.value = true; - const params = props.pagination.params - ? isRef<Param>(props.pagination.params) - ? props.pagination.params.value - : props.pagination.params - : {}; + const params = props.pagination.params ? unref(props.pagination.params) : {}; await os .api(props.pagination.endpoint, { ...params, @@ -356,12 +364,12 @@ const fetchMoreAhead = async (): Promise<void> => { if (res.length > SECOND_FETCH_LIMIT) { res.pop(); items.value = props.pagination.reversed - ? [...res].reverse().concat(items.value) + ? res.toReversed().concat(items.value) : items.value.concat(res); more.value = true; } else { items.value = props.pagination.reversed - ? [...res].reverse().concat(items.value) + ? res.toReversed().concat(items.value) : items.value.concat(res); more.value = false; } diff --git a/packages/client/src/components/MkReactedUsers.vue b/packages/client/src/components/MkReactedUsers.vue index ae7a1d6d7d..76b86ed46f 100644 --- a/packages/client/src/components/MkReactedUsers.vue +++ b/packages/client/src/components/MkReactedUsers.vue @@ -23,7 +23,13 @@ }}</span> </button> </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 v-else> <MkLoading /> @@ -36,6 +42,9 @@ import type { entities } from "firefish-js"; import MkReactionIcon from "@/components/MkReactionIcon.vue"; import MkUserCardMini from "@/components/MkUserCardMini.vue"; import * as os from "@/os"; +import MkPagination, { + type MkPaginationType, +} from "@/components/MkPagination.vue"; const props = defineProps<{ noteId: entities.Note["id"]; @@ -44,16 +53,22 @@ const props = defineProps<{ const note = ref<entities.Note>(); const tab = ref<string | null>(null); const reactions = ref<string[]>(); -const users = ref(); -async function updateUsers(): void { - const res = await os.api("notes/reactions", { +const pagingComponent = ref<MkPaginationType<"notes/reactions"> | null>(null); + +const pagination = { + endpoint: "notes/reactions" as const, + params: { noteId: props.noteId, 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); @@ -64,7 +79,7 @@ onMounted(() => { }).then(async (res) => { reactions.value = Object.keys(res.reactions); note.value = res; - await updateUsers(); + // updateUsers(); }); }); </script> diff --git a/packages/client/src/components/MkRenoteButton.vue b/packages/client/src/components/MkRenoteButton.vue index 4f0a669c11..3925dc8c5a 100644 --- a/packages/client/src/components/MkRenoteButton.vue +++ b/packages/client/src/components/MkRenoteButton.vue @@ -27,7 +27,7 @@ import Ripple from "@/components/MkRipple.vue"; import XDetails from "@/components/MkUsersTooltip.vue"; import { pleaseLogin } from "@/scripts/please-login"; import * as os from "@/os"; -import { isSignedIn, me } from "@/me"; +import { me } from "@/me"; import { useTooltip } from "@/scripts/use-tooltip"; import { i18n } from "@/i18n"; import { defaultStore } from "@/store"; @@ -72,17 +72,9 @@ useTooltip(buttonRef, async (showing) => { ); }); -const hasRenotedBefore = ref(false); - -if (isSignedIn(me)) { - os.api("notes/renotes", { - noteId: props.note.id, - userId: me.id, - limit: 1, - }).then((res) => { - hasRenotedBefore.value = res.length > 0; - }); -} +const hasRenotedBefore = ref( + props.note.myRenoteCount && props.note.myRenoteCount > 0, +); const renote = (viaKeyboard = false, ev?: MouseEvent) => { pleaseLogin(); diff --git a/packages/client/src/components/MkSubNoteContent.vue b/packages/client/src/components/MkSubNoteContent.vue index 7d350085bb..a0664c28e9 100644 --- a/packages/client/src/components/MkSubNoteContent.vue +++ b/packages/client/src/components/MkSubNoteContent.vue @@ -1,7 +1,7 @@ <template> <p v-if="note.cw != null" class="cw"> <MkA - v-if="conversation && note.renoteId == parentId" + v-if="conversation && note.renoteId == parentId && parentId != null" :to=" detailedView ? `#${parentId}` : `${notePage(note)}#${parentId}` " @@ -198,8 +198,8 @@ import icon from "@/scripts/icon"; const props = defineProps<{ note: entities.Note; - parentId?; - conversation?; + parentId?: string; + conversation?: entities.Note[]; detailed?: boolean; detailedView?: boolean; }>(); diff --git a/packages/client/src/pages/follow.vue b/packages/client/src/pages/follow.vue index a8708978ef..3be549537f 100644 --- a/packages/client/src/pages/follow.vue +++ b/packages/client/src/pages/follow.vue @@ -24,16 +24,16 @@ async function follow(user): Promise<void> { }); } -const acct = new URL(location.href).searchParams.get("acct"); -if (acct == null) { +const acctUri = new URL(location.href).searchParams.get("acct"); +if (acctUri == null) { throw new Error("acct required"); } let promise; -if (acct.startsWith("https://")) { +if (acctUri.startsWith("https://")) { promise = os.api("ap/show", { - uri: acct, + uri: acctUri, }); promise.then((res) => { if (res.type === "User") { @@ -50,7 +50,7 @@ if (acct.startsWith("https://")) { } }); } else { - promise = os.api("users/show", acct.parse(acct)); + promise = os.api("users/show", acct.parse(acctUri)); promise.then((user) => { follow(user); }); diff --git a/packages/client/src/scripts/use-note-capture.ts b/packages/client/src/scripts/use-note-capture.ts index 09043044cd..dac3e140a3 100644 --- a/packages/client/src/scripts/use-note-capture.ts +++ b/packages/client/src/scripts/use-note-capture.ts @@ -9,6 +9,7 @@ export function useNoteCapture(props: { rootEl: Ref<HTMLElement | null>; note: Ref<entities.Note>; isDeletedRef: Ref<boolean>; + onReplied?: (note: entities.Note) => void; }) { const note = props.note; const connection = isSignedIn(me) ? useStream() : null; @@ -19,6 +20,16 @@ export function useNoteCapture(props: { if (id !== note.value.id) return; 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": { const reaction = body.reaction; diff --git a/packages/firefish-js/src/api.types.ts b/packages/firefish-js/src/api.types.ts index fc0407b906..065de49025 100644 --- a/packages/firefish-js/src/api.types.ts +++ b/packages/firefish-js/src/api.types.ts @@ -801,7 +801,12 @@ export type Endpoints = { res: null; }; "notes/reactions": { - req: { noteId: Note["id"]; type?: string | null; limit?: number }; + req: { + noteId: Note["id"]; + type?: string | null; + limit?: number; + offset?: number; + }; res: NoteReaction[]; }; "notes/reactions/create": { @@ -815,6 +820,7 @@ export type Endpoints = { sinceId?: Note["id"]; untilId?: Note["id"]; noteId: Note["id"]; + filter?: "all" | "renote" | "quote"; }; res: Note[]; }; diff --git a/packages/firefish-js/src/entities.ts b/packages/firefish-js/src/entities.ts index 9cc9d915b8..db9e18a741 100644 --- a/packages/firefish-js/src/entities.ts +++ b/packages/firefish-js/src/entities.ts @@ -175,9 +175,11 @@ export type Note = { channelId?: Channel["id"]; channel?: Channel; myReaction?: string; + myRenoteCount?: number; reactions: Record<string, number>; renoteCount: number; repliesCount: number; + quoteCount: number; poll?: { expiresAt: DateString | null; multiple: boolean;