diff --git a/lib/mastodon/routes/accounts.js b/lib/mastodon/routes/accounts.js index 96e90ad..444d8b9 100644 --- a/lib/mastodon/routes/accounts.js +++ b/lib/mastodon/routes/accounts.js @@ -439,7 +439,7 @@ router.get("/api/v1/accounts/:id/statuses", tokenRequired, scopeRequired("read", ]; } if (req.query.exclude_replies === "true") { - baseFilter.inReplyTo = { $exists: false }; + baseFilter.$or = [{ inReplyTo: { $exists: false } }, { inReplyTo: "" }]; } if (req.query.exclude_reblogs === "true") { baseFilter.type = { $ne: "boost" }; diff --git a/lib/mastodon/routes/timelines.js b/lib/mastodon/routes/timelines.js index 41355be..ddafa86 100644 --- a/lib/mastodon/routes/timelines.js +++ b/lib/mastodon/routes/timelines.js @@ -114,7 +114,7 @@ router.get("/api/v1/timelines/public", async (req, res, next) => { // Public timeline: only public visibility, no context items, no replies const baseFilter = { isContext: { $ne: true }, - inReplyTo: { $exists: false }, + $or: [{ inReplyTo: { $exists: false } }, { inReplyTo: "" }], visibility: "public", }; @@ -220,11 +220,18 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => { const limit = parseLimit(req.query.limit); const hashtag = req.params.hashtag; + // Match the hashtag case-insensitively and as a prefix segment + // (e.g. "politics" matches "on/politics", "politics/foo"). + // Escape to prevent regex injection — hashtag comes from a URL param. + const escapedHashtag = hashtag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const tagRegex = new RegExp(`(^|/)${escapedHashtag}(/|$)`, "i"); + const baseFilter = { isContext: { $ne: true }, - inReplyTo: { $exists: false }, + // inReplyTo is absent on non-replies; legacy docs may store "" — exclude both + $or: [{ inReplyTo: { $exists: false } }, { inReplyTo: "" }], visibility: { $in: ["public", "unlisted"] }, - category: hashtag, + category: tagRegex, }; const { filter, sort, reverse } = buildPaginationQuery(baseFilter, { diff --git a/lib/timeline-store.js b/lib/timeline-store.js index 5b59a18..962c1e9 100644 --- a/lib/timeline-store.js +++ b/lib/timeline-store.js @@ -348,11 +348,12 @@ export async function extractObjectData(object, options = {}) { // Attachment extraction failed — non-critical } - // In-reply-to — Fedify uses replyTargetId (non-fetching) - const inReplyTo = object.replyTargetId?.href || ""; + // In-reply-to — Fedify uses replyTargetId (non-fetching). + // Only set when non-empty — $exists:false filters depend on field absence. + const inReplyTo = object.replyTargetId?.href || undefined; // Quote URL — Fedify reads quoteUrl / _misskey_quote / quoteUri - const quoteUrl = object.quoteUrl?.href || ""; + const quoteUrl = object.quoteUrl?.href || undefined; // Interaction counts — not fetched at ingest time. The three collection // fetches (getReplies, getLikes, getShares) each trigger an HTTP round-trip @@ -382,8 +383,8 @@ export async function extractObjectData(object, options = {}) { photo, video, audio, - inReplyTo, - quoteUrl, + ...(inReplyTo ? { inReplyTo } : {}), + ...(quoteUrl ? { quoteUrl } : {}), counts, pollOptions, votersCount,