feat(feed): timeline replies setting + involvement-aware, reliable notifications (#1060) - #1061
Merged
Merged
Conversation
…ons (#1060) Following Mastodon publishers surfaced three gaps: - Replies were ingested as ordinary timeline cards with the inReplyTo discarded. Now stored (in_reply_to_uri, additive column) and filtered by the new timeline_show_replies setting (default off): replies to others hidden, replies to your own posts always shown and marked. - The Android notifier judged newness by published_at, silently skipping any post that arrived (federation retries) after a newer-published one. TimelineEntry now exposes received_at and the high-water uses it (published_at fallback for rolling deploy). Replies notify only when in_reply_to_mine. - The in-app notifications toggle looked on while Android's app-level permission was off and the worker silently skipped posting — the Account screen now warns and deep-links to the system settings, re-checked on resume. SQL-level reply filtering keeps pages full and cursors stable; MCP list_timeline gets the same filter (webHost already in its options). Settings parity: REST + MCP via updateUserSettingsBodySchema, web Settings toggle under Feed & Followers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CwoP1SqJhHgHiEEoEQtUjT
fiddur
marked this pull request as ready for review
August 23, 2026 16:23
fiddur
commented
Aug 23, 2026
fiddur
left a comment
Owner
Author
There was a problem hiding this comment.
✅ Approved
Solid, well-scoped change. I traced the whole path and found no blocking issues.
What I verified:
- Own-post prefix matches reality.
ownObjectPrefix(webHost, user)produces{webHost}/users/{user}/feed/, which is exactly whatctx.getObjectUri(Note, …)emits indeliver.ts(federation is created withorigin: webHostinapi.ts), andUSERNAME_REGEX(^[a-z][a-z0-9_]{2,30}$) makes theencodeURIComponenta no-op, so the SQLLIKEprefix and the JSstartsWithcan't disagree. escapeLikeis correct and necessary — Postgres' default LIKE escape is backslash and the pattern is bound as a parameter, so no literal-escaping hazard; and usernames legally contain_, so the escaping is load-bearing.- No
in_reply_to_uriwipe. Retro-enrichment goes through the dedicatedUPDATEstatements (setTimelineEntryStructured/markEnrichTransientFailure), notupsertTimelineEntry, so the new column survives enrichment. The backfiller sharesingestNoteForRecipient, so backfilled posts get the reply link too. - Keyset pagination stays sound with the filter pushed into SQL: the cursor is always taken from a row that passed the filter, so pages don't skip or repeat.
received_atbecoming a required response field is safe for older installed Android builds —appJsonis configured withignoreUnknownKeys = true(and the new build makes it nullable for an older backend), andserializeTimelineEntryis the only producer of the DTO.timeline_show_replies: falsepersists correctly —upsertUserSettingsfilters onlyundefined, not falsy.- Failing
getSettingsfalls back toshow_replies: false, i.e. fail-closed to the documented default. Good.
Non-blocking — fold into a later PR, don't re-roll this one
Please don't push fixes for these here; a push starts another review round for no real benefit. Batch them with other work.
- Self-replies are hidden too, which the docs don't say. The filter is
in_reply_to_uri IS NULL OR in_reply_to_uri LIKE '<my prefix>%', so a followed author's own thread continuations are also hidden when the setting is off — a 3-post thread shows only post 1.docs/features/feed.mdsays it "hides followed actors' replies to other people", and the settings copy frames the toggle the same way; the web copy's "you see their top-level posts only" is accurate, but the "when on" sentence and the doc line aren't. Worth aligning the wording (or later letting self-replies through — Mastodon's home timeline does show them). - One-time duplicate notifications across the upgrade. The stored high-water was a
published_atand is now compared againstreceived_at, which is always ≥published_at(published is clamped tomin(published, now)at ingest, received is insert time). So on the first run after the switch, already-notified posts can re-notify — capped atMAX_PER_RUN(8) and self-healing after one run, so only worth knowing about. - No test for
escapeLike. Since usernames may contain_, a case like userfoo_bar(prefix…/users/foo_bar/feed/) not matching…/users/fooxbar/feed/…is exactly what the escaping buys and would be cheap to pin down. - SSE fires for hidden replies.
onNewEntryruns for any genuinely new row, including a reply the reader's filter excludes, so the web gets anevent: newping and refetches to find nothing new. - A couple of the new comments restate the code: the
/** The inReplyTo object id when the post is a reply, or null for a top-level post. */doc onin_reply_to_uriindb/timeline.ts, and the// A reply's target id, so the timeline can filter…line innoteToTimelineInput(the field name plus the schema description already carry it). Thereceived_at-rationale andareNotificationsEnabled-warning comments do earn their keep. timeline_show_repliesis inserted out of alphabetical order inupdateSettingsInputSchemaanduserSettingsResponseSchema(beforesensitivity_areas/strava_connected), where the neighbouring keys are otherwise sorted — unlikesettingsWithDefaultsSchema, where it's placed correctly.
This was referenced Aug 23, 2026
fiddur
added a commit
that referenced
this pull request
Aug 24, 2026
…round 1) - Backstamp migration marks rows that already carry in_reply_to_uri (ingested between #1061 and now) as checked, and a backfill fetch that yields no usable AS2 object (404, authorized-fetch 401, HTML body, host down) only stamps reply_checked_at — never overwrites stored reply state (new markTimelineEntryReplyChecked) - TimelineReply.url is kept only when http(s): a hostile origin — reachable by any stranger via mention ingestion — could otherwise put a javascript: href in the web's reply list - resolveAuthor no longer throws on a non-URL attributedTo (it collapsed the whole thread to empty via the route's catch) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CwoP1SqJhHgHiEEoEQtUjT
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1060. Three gaps from following Mastodon publishers (Fredrik's report):
1. Replies as their own cards — now a setting
Ingest discarded
inReplyToentirely, so followed actors' replies to strangers landed as ordinary cards with no way to filter. Now:timeline_entry.in_reply_to_uristored at ingest (additive column +tableCreationOrderentry, schema-guard test covers it).timeline_show_repliesuser setting (default off): replies to other people are hidden from the home timeline; replies to your own posts always show, marked "↩ replied to you" on the card. Filtering happens in SQL so pages stay full and cursors stable; the own-post match is a LIKE against{origin}/users/{me}/feed/with wildcards escaped.list_timeline; settings parity viaupdateUserSettingsBodySchema(REST + MCP + web toggle under Feed & Followers); Kotlin models regenerated.2. Notifications: reply-aware and race-free
TimelineEntrynow exposesreceived_atand the notifier's high-water mark uses it (fallbackpublished_atfor a rolling deploy). The old publish-time high-water silently skipped any post that arrived after a newer-published one — federation retries make that routine. Unit test pins the exact scenario.in_reply_to_mine— new posts always, per Mastodon-like involvement. (Per-thread notification subscriptions are follow-up scope, tracked in Timeline replies: show/hide setting + involvement-aware notifications + notifier reliability #1060's plan.)3. The silent-permission trap (Fredrik's actual root cause)
The in-app toggle showed ON while Android's app-level notification permission was off, and the worker silently skipped posting (
areNotificationsEnabled()check). The Account screen now shows a warning with an "Open notification settings" button whenever the toggle is on but the system permission is off, re-checked on every resume so it clears after granting.Tests: 3032 backend (unit + integration, incl. new SQL-filter integration case and reply/
received_atservice tests), 586 web, Android unit tests green (compileDebugKotlin+testDebugUnitTestlocally); whole-monorepo check green.🤖 Generated with Claude Code
https://claude.ai/code/session_01CwoP1SqJhHgHiEEoEQtUjT