fix: hashtag timeline returns empty results

- inReplyTo stored as empty string "" for non-replies in timeline-store.js;
  filters using {$exists: false} missed these docs → hashtag/public timelines empty
- Fix: omit inReplyTo/quoteUrl fields entirely when empty (undefined spread)
- Fix: update all {$exists: false} filters to also match {inReplyTo: ""}
- Fix: hashtag timeline query uses case-insensitive prefix regex so
  "politics" matches "on/politics", "wandern" matches stored "wandern"
- DB migration: unset inReplyTo:"" on 620 existing ap_timeline documents
This commit is contained in:
svemagie
2026-05-10 18:34:50 +02:00
parent bda09b9866
commit bd26b25961
3 changed files with 17 additions and 9 deletions
+1 -1
View File
@@ -439,7 +439,7 @@ router.get("/api/v1/accounts/:id/statuses", tokenRequired, scopeRequired("read",
]; ];
} }
if (req.query.exclude_replies === "true") { if (req.query.exclude_replies === "true") {
baseFilter.inReplyTo = { $exists: false }; baseFilter.$or = [{ inReplyTo: { $exists: false } }, { inReplyTo: "" }];
} }
if (req.query.exclude_reblogs === "true") { if (req.query.exclude_reblogs === "true") {
baseFilter.type = { $ne: "boost" }; baseFilter.type = { $ne: "boost" };
+10 -3
View File
@@ -114,7 +114,7 @@ router.get("/api/v1/timelines/public", async (req, res, next) => {
// Public timeline: only public visibility, no context items, no replies // Public timeline: only public visibility, no context items, no replies
const baseFilter = { const baseFilter = {
isContext: { $ne: true }, isContext: { $ne: true },
inReplyTo: { $exists: false }, $or: [{ inReplyTo: { $exists: false } }, { inReplyTo: "" }],
visibility: "public", visibility: "public",
}; };
@@ -220,11 +220,18 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => {
const limit = parseLimit(req.query.limit); const limit = parseLimit(req.query.limit);
const hashtag = req.params.hashtag; 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 = { const baseFilter = {
isContext: { $ne: true }, 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"] }, visibility: { $in: ["public", "unlisted"] },
category: hashtag, category: tagRegex,
}; };
const { filter, sort, reverse } = buildPaginationQuery(baseFilter, { const { filter, sort, reverse } = buildPaginationQuery(baseFilter, {
+6 -5
View File
@@ -348,11 +348,12 @@ export async function extractObjectData(object, options = {}) {
// Attachment extraction failed — non-critical // Attachment extraction failed — non-critical
} }
// In-reply-to — Fedify uses replyTargetId (non-fetching) // In-reply-to — Fedify uses replyTargetId (non-fetching).
const inReplyTo = object.replyTargetId?.href || ""; // 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 // 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 // Interaction counts — not fetched at ingest time. The three collection
// fetches (getReplies, getLikes, getShares) each trigger an HTTP round-trip // fetches (getReplies, getLikes, getShares) each trigger an HTTP round-trip
@@ -382,8 +383,8 @@ export async function extractObjectData(object, options = {}) {
photo, photo,
video, video,
audio, audio,
inReplyTo, ...(inReplyTo ? { inReplyTo } : {}),
quoteUrl, ...(quoteUrl ? { quoteUrl } : {}),
counts, counts,
pollOptions, pollOptions,
votersCount, votersCount,