Skip to content

feat(feed): timeline replies setting + involvement-aware, reliable notifications (#1060) - #1061

Merged
fiddur merged 1 commit into
developfrom
timeline-replies-notifications
Aug 23, 2026
Merged

feat(feed): timeline replies setting + involvement-aware, reliable notifications (#1060)#1061
fiddur merged 1 commit into
developfrom
timeline-replies-notifications

Conversation

@fiddur

@fiddur fiddur commented Aug 23, 2026

Copy link
Copy Markdown
Owner

Closes #1060. Three gaps from following Mastodon publishers (Fredrik's report):

1. Replies as their own cards — now a setting

Ingest discarded inReplyTo entirely, so followed actors' replies to strangers landed as ordinary cards with no way to filter. Now:

  • timeline_entry.in_reply_to_uri stored at ingest (additive column + tableCreationOrder entry, schema-guard test covers it).
  • New timeline_show_replies user 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.
  • Same filter on MCP list_timeline; settings parity via updateUserSettingsBodySchema (REST + MCP + web toggle under Feed & Followers); Kotlin models regenerated.

2. Notifications: reply-aware and race-free

  • TimelineEntry now exposes received_at and the notifier's high-water mark uses it (fallback published_at for 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.
  • Replies notify only when 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_at service tests), 586 web, Android unit tests green (compileDebugKotlin + testDebugUnitTest locally); whole-monorepo check green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CwoP1SqJhHgHiEEoEQtUjT

…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
fiddur marked this pull request as ready for review August 23, 2026 16:23

@fiddur fiddur left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 what ctx.getObjectUri(Note, …) emits in deliver.ts (federation is created with origin: webHost in api.ts), and USERNAME_REGEX (^[a-z][a-z0-9_]{2,30}$) makes the encodeURIComponent a no-op, so the SQL LIKE prefix and the JS startsWith can't disagree.
  • escapeLike is 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_uri wipe. Retro-enrichment goes through the dedicated UPDATE statements (setTimelineEntryStructured / markEnrichTransientFailure), not upsertTimelineEntry, so the new column survives enrichment. The backfiller shares ingestNoteForRecipient, 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_at becoming a required response field is safe for older installed Android builds — appJson is configured with ignoreUnknownKeys = true (and the new build makes it nullable for an older backend), and serializeTimelineEntry is the only producer of the DTO.
  • timeline_show_replies: false persists correctlyupsertUserSettings filters only undefined, not falsy.
  • Failing getSettings falls back to show_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.

  1. 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.md says 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).
  2. One-time duplicate notifications across the upgrade. The stored high-water was a published_at and is now compared against received_at, which is always ≥ published_at (published is clamped to min(published, now) at ingest, received is insert time). So on the first run after the switch, already-notified posts can re-notify — capped at MAX_PER_RUN (8) and self-healing after one run, so only worth knowing about.
  3. No test for escapeLike. Since usernames may contain _, a case like user foo_bar (prefix …/users/foo_bar/feed/) not matching …/users/fooxbar/feed/… is exactly what the escaping buys and would be cheap to pin down.
  4. SSE fires for hidden replies. onNewEntry runs for any genuinely new row, including a reply the reader's filter excludes, so the web gets an event: new ping and refetches to find nothing new.
  5. 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 on in_reply_to_uri in db/timeline.ts, and the // A reply's target id, so the timeline can filter… line in noteToTimelineInput (the field name plus the schema description already carry it). The received_at-rationale and areNotificationsEnabled-warning comments do earn their keep.
  6. timeline_show_replies is inserted out of alphabetical order in updateSettingsInputSchema and userSettingsResponseSchema (before sensitivity_areas / strava_connected), where the neighbouring keys are otherwise sorted — unlike settingsWithDefaultsSchema, where it's placed correctly.

@fiddur
fiddur merged commit baad6af into develop Aug 23, 2026
4 checks passed
@fiddur
fiddur deleted the timeline-replies-notifications branch August 23, 2026 16:32
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Timeline replies: show/hide setting + involvement-aware notifications + notifier reliability

1 participant