Repository navigation
Conversation
96c785f to
c8f9587
Compare
d803d95 to
1e1efab
Compare
c8f9587 to
62a9feb
Compare
1e1efab to
69da0b7
Compare
62a9feb to
5edb087
Compare
…web app Reading the newest message in a channel or thread now writes activity:<channel> or thread-activity:<root>. Other fully visible messages get msg:<id> marks after a 300ms dwell. Opening a channel no longer marks the whole channel read (forums still do), and opening a thread no longer marks every reply. Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
… Activity rows - Keep msg: marks for visible replies; thread catch-up reads a reply only while its root is loaded. - Cut activity:/thread-activity: at the bottom row, never past now. - A nested thread head writes msg: marks only. - Key the reading dwell on message content so rebuilds do not restart it. - Check each grouped Activity event against its own marks. Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Do not republish message marks merged from other devices; the web app prunes them once a catch-up mark reads the message. Cap the slot at the web app's 40 KiB budget, keeping broad marks first, so a large slot no longer stops sync. Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Opening a DM writes its channel mark through the newest loaded message, replies included, as mobile did before. Reading to the bottom of a channel the reader marked unread clears that manual unread. It does not move the channel mark, so unseen mentions, replies and messages marked unread stay unread. Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
a2b477c to
47fc23f
Compare
jedwards27
left a comment
There was a problem hiding this comment.
:bot: Jude’s code review agent
Reviewed base 932228936464a0bdd44eae0f5e95ca5bbcd819b9 through exact head 47fc23f8e6a639c9065bc1632dac18f6e4ba4357. Blocking findings remain; I do not recommend merging this head. Per this repository's agent-review policy, I am posting a comment rather than selecting GitHub's Request Changes state.
Findings
1. Automatic selective reads make local read-state persistence unbounded
The new production paths create a durable msg: context for each newly visible message (mobile/lib/features/channels/reading_marks.dart:68-85, consumed by channel_detail_page/message_list.dart:577-593 and thread_detail_page.dart:720-734). The 40 KiB cap is applied only while constructing a relay payload in _currentContexts() (mobile/lib/shared/read_state/read_state_manager.dart:502-510). Every local mutation still serializes the complete _effectiveState, _publishableContextIds, and _contextSourceCreatedAt collections into three SharedPreferences values without horizon or count pruning (read_state_manager.dart:172-200,526-532; read_state_storage.dart:82-101). The new 1,400-mark regression explicitly preserves marks omitted from publication (mobile/test/features/channels/read_state/read_state_manager_test.dart:129-151).
That turns normal continued reading into unbounded local JSON growth and increasingly expensive serialization on the UI isolate, contrary to the bounded storage/background-work requirement in VISION_MOBILE.md:28-29. The desktop reference already prunes msg:/thread: entries by horizon and count and filters both metadata structures to the retained keys (desktop/src/features/channels/readState/readStateStorage.ts:106-176).
Author action: apply synchronized horizon/count pruning to locally persisted prunable msg:/thread: contexts, filtering publishable IDs and source timestamps to the same retained key set while preserving broad contexts. Add high-volume plus restart/hydration coverage proving all three persisted structures remain bounded and aligned.
Verification owner: author and mobile CI; reviewer to re-check retention semantics.
2. A read mark can be stranded when its debounce fires during an in-flight publish
_publish() returns immediately when _isPublishing is true (mobile/lib/shared/read_state/read_state_manager.dart:385-393). The active operation snapshots contexts once (:399-407), and finalization only clears the flag/completer (:451-457); it does not schedule a trailing pass.
A concrete schedule is: publish A takes its snapshot; mark B arrives and schedules its five-second timer; B's timer fires while A's relay submit remains pending, so B's _publish() call returns; A succeeds and records only its prior snapshot. B remains local but has no relay retry until an unrelated later mutation, reinitialization, or lifecycle flush. The new repeated automatic visible-row writes make this race an ordinary production path rather than an exceptional manual action.
Author action: make publish coalescing dirty-aware and guarantee a trailing publish whenever state changes after the active snapshot, including failure paths (or await/coalesce callers with the same guarantee). Add a controlled relay regression that parks submit A, marks B after A snapshots, lets B's debounce fire, releases A, and proves a second accepted event contains B.
Verification owner: author and mobile CI; reviewer to re-check async lifecycle.
3. The 40 KiB retention path can replace a slot with incomplete ov_* durability state
Incoming contexts are merged wholesale (mobile/lib/shared/read_state/read_state_manager.dart:245-257,318-333), and republishesMergedContext() excludes only msg: (mobile/lib/shared/read_state/read_state_format.dart:37-43). Consequently mobile republishes merged ov_s: / ov_c: / ov_b: keys despite not implementing the override protocol's full-state loading and group semantics.
The new retention function merely prioritizes those keys, then truncates entry-by-entry at 40 KiB (read_state_format.dart:45-86). A sufficiently large override set is therefore omitted, and a group crossing the boundary can be split, while the truncated replacement event is still submitted. Before this PR, an oversized encryption stopped sync; this change converts that visible stop into partial override replication.
NIP-RS requires complete-group validation before merge (docs/nips/NIP-RS.md:114), forbids budget eviction of override entries (:674-678), requires frontier plus override siblings to travel together (:656-662), and requires leaving the previous primary in place rather than publishing an omitted merged set (:691-693). Violating those constraints can resurrect cleared manual-unread state or corrupt convergence.
Author action: preserve override groups atomically and never evict any ov_* state; if all merged override state cannot fit, fail closed without replacing the prior slot. If mobile is instead intentionally made override-unaware, the migration must also prove it does not abandon override state already carried in its existing coordinate. Add over-budget and boundary-splitting regressions proving no partial or omitted override-bearing replacement is submitted.
Verification owner: author and mobile CI; reviewer to re-check against NIP-RS.
Additional evidence and residual gaps
- Exact-head CI was green for
Clients / Mobile,Mobile,Clients / Results,Mobile Swift Domain / Mobile Swift,Mobile Swift Domain / Results, DCO, Semgrep OSS, and zizmor when checked. CI does not exercise the race schedule or the local-storage lifetime above. - Static/widget-test inspection found no separate defect in bottom dwell, selective visible marks, nested-thread isolation, DM/forum open-read behavior, manual-unread preservation, or Activity grouping/deep-linking.
- Local Flutter execution was blocked before tests by this review machine's
objective_cnative-asset/Xcode setup. This is a reviewer tooling confidence gap, not author rework. - No physical-device iOS/Android observation was performed for dwell timing, tall-message bottom detection, rebuild/app-lifecycle races, or accessibility behavior. Verification owner: mobile release/QA.
- Existing protocol debt, not attributed to this PR:
isValidReadStateDTagaccepts arbitrary non-empty ASCII up to 64 characters (read_state_format.dart:138-154) rather than exactly 32 lowercase hexadecimal characters as required bydocs/nips/NIP-RS.md:55.
jedwards27
left a comment
There was a problem hiding this comment.
:bot: Jude’s code review agent
Reviewed exact head 47fc23f8e6a639c9065bc1632dac18f6e4ba4357 against base 932228936464a0bdd44eae0f5e95ca5bbcd819b9.
The mobile/product behavior is well-covered and the exact-head Mobile, Clients/Mobile, Mobile Swift, DCO, Semgrep, and zizmor checks are green. However, the new automatic read producer and publication cap leave four author-actionable correctness defects:
-
Published-size accounting ignores JSON escaping (
mobile/lib/shared/read_state/read_state_format.dart:68-84). The code charges raw UTF-8 key bytes, while accepted keys can contain characters such as"and\\thatjsonEncodeexpands (:201-211). A reproduction with legal 245-bytemsg:keys retained 162 entries at an accounted 40,861 bytes, but encoded to 80,064 bytes—14,528 bytes beyond the ~64 KiB NIP-44 limit. This recreates the session-disabling publish failure the cap is meant to prevent.Author action: budget encoded entry/final-payload bytes, and add an escaping-heavy regression asserting the final plaintext stays within the cap.
-
Automatic selective reads make local read-state persistence unbounded. The new channel/thread loops durably emit
msg:contexts for visible messages (mobile/lib/features/channels/reading_marks.dart:68-85; callers inchannel_detail_page/message_list.dart:577-593andthread_detail_page.dart:720-734). Only publication is capped (read_state_manager.dart:506); local persistence still serializes all effective contexts, publishable IDs, and source timestamps (read_state_manager.dart:526-532;read_state_storage.dart:82-101). Continued reading therefore grows local JSON and every-write serialization indefinitely.Author action: prune prunable
msg:/thread:contexts by a bounded horizon/count before persistence, keeping contexts, publishable IDs, and source timestamps aligned while preserving broad contexts. Add high-volume restart/hydration coverage for all three stored structures. -
A mark can be stranded when its debounce fires during an in-flight publish.
_publish()returns immediately while_isPublishing(read_state_manager.dart:385-393), after the active operation already snapshotted its contexts (:399-407), and completion does not schedule a trailing pass (:451-457). If mark B's timer fires while submit A is pending, A records only its old snapshot and B has no relay retry until an unrelated later mutation/lifecycle flush.Author action: make coalescing dirty-aware and guarantee a trailing publish when state changes after the active snapshot, including failure paths. Add a controlled relay test that parks submit A, marks B, lets B's debounce fire, releases A, and proves a second accepted event contains B.
-
The 40 KiB retention path can publish an incomplete reserved override replica. Incoming contexts are merged wholesale (
read_state_manager.dart:245-257,318-333), andrepublishesMergedContextexcludes onlymsg:(read_state_format.dart:37-43), so unsupportedov_*state becomes publishable. Entry-by-entry truncation (:45-86) can split or omit an override group, converting the prior visible encryption failure into silent partial replication that can corrupt manual-unread convergence or resurrect cleared state.Author action: either fully support override replication with complete-group validation, atomic retention, and fail-closed publication when all override state cannot fit, or exclude unsupported reserved override keys from mobile adoption/republishing. Add over-budget and group-boundary coverage proving no partial override-bearing event is submitted.
Confidence gaps (not author defects): local focused Flutter execution was blocked before tests by objective_c native-asset/Xcode discovery (Bad state: No element), and no physical-device dwell/scroll/lifecycle observation was performed. CI/mobile release verification owns those gaps; they are not additional rework requests.
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Three P2 correctness blockers are detailed inline. Please address them with regression coverage before merging.
Reviewed head 47fc23f8e6a639c9065bc1632dac18f6e4ba4357 against base 932228936464a0bdd44eae0f5e95ca5bbcd819b9. Existing Mobile CI is green. Validation combines source tracing, an extracted-source Dart retention probe, and independent reviewer widget probes; no live cross-device workflow or broad local suite was run. The Android occlusion finding is source-traced, not device-reproduced.
Optional product follow-up: under the explicitly chosen full-row visibility policy, a mention taller than the phone viewport cannot become read by scrolling through it and requires explicit Mark read. Consider an oversized-row reading policy separately; this is not a merge blocker.
…d heads - Count JSON-encoded bytes in the 40 KiB slot budget, so escaped keys cannot push the encrypted payload past the NIP-44 limit. - Reserve a quarter of the slot for the most recently written marks, as buzz-app does, so old channel and thread marks cannot starve new reads. - Save at most 1,000 msg:/thread: marks within the 7-day horizon, like desktop's pruneStaleContexts. Channel and catch-up marks are kept, and all three saved structures hold the same keys. - Publish again when state changes during an in-flight publish, including after a failed publish. - Stop taking ov_*/esc: override keys from other slots. Carry the ones already in this device's own slot whole, ahead of the budget; if they do not fit, leave the slot as it is. - Read a fully visible, non-deleted thread head, including in head-only threads and threads opened directly. - Use the full covered height (composer plus Android keyboard) as the reading edge in channels and threads, and restart the dwell when it changes. Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
|
Thanks for the review. The fixes are in dd7f76d. Encoded size. Each entry's cost is the Local persistence. Trailing publish. If Override keys. Mobile is now override-unaware, and it does not abandon override state:
New tests:
Local gates on dd7f76d: |
jedwards27
left a comment
There was a problem hiding this comment.
:bot: Jude’s code review agent
Re-reviewed base 932228936464a0bdd44eae0f5e95ca5bbcd819b9 through exact head dd7f76dc52d46c777addf1c4e4dbfd5c5ca6e7e5. The prior encoded-budget, bounded-persistence, and trailing-publish defects are fixed. Two author-actionable defects remain, so I do not recommend merging this head.
Findings
P1 — a covered or backgrounded DM route can consume a newly loaded reply without visibility
ChannelDetailPage now derives a DM's automatic open-read timestamp from every loaded event, including replies (mobile/lib/features/channels/channel_detail_page.dart:209-224), and advances the whole-channel mark whenever that timestamp changes (:544-556). That effect has no current-route or foreground-lifecycle fence. The visible channel/thread dwell paths do have those fences (channel_detail_page/message_list.dart:605-619; thread_detail_page.dart:745-760).
Concrete failure: open a DM, cover it with a thread or another pushed route (or background the app), then receive/load a newer DM reply. The covered page advances the DM channel marker despite the reply never being visible. Activity subsequently treats the reply as read through that channel marker (mobile/lib/features/activity/inbox_read_state.dart:13-29), removing unread/Activity state. This PR makes the failure cover replies by expanding the open-read calculation beyond top-level events.
The foreground-open test (mobile/test/features/channels/channel_detail_page_test.dart:2166-2221) and covered non-DM thread test (:2265-2333) do not exercise this DM lifecycle case.
Author action: gate DM automatic open/read updates on the route being current and the app being foregrounded, while retaining immediate foreground DM-open behavior. Add deterministic widget regressions for (1) a DM covered by a pushed route plus a newly loaded reply and (2) a paused/hidden app plus a newly loaded reply; assert no channel advance until current/resumed, then assert the intended read occurs.
Verification owner: author and Mobile CI; reviewer to re-check the production lifecycle seam.
P2 — carried override siblings can be published without their required frontier
The new carrier reserves only keys classified as ov_* or esc: (mobile/lib/shared/read_state/read_state_format.dart:76-82; passed from read_state_manager.dart:559-571). An ordinary matching frontier remains in the independently evictable marks map. retainReadStateContexts() copies carried entries first and then trims ordinary entries by scope/recency (read_state_format.dart:119-185). Under budget pressure, all three ov_*:<ctx> siblings can survive while <ctx> is evicted.
That violates NIP-RS's requirement that a context's frontier and all override siblings travel in the same event (docs/nips/NIP-RS.md:656-662). An executable model of this exact algorithm using a legal 240-byte context ID and 2,000 newer broad marks produced a 40,923-byte replacement containing all override siblings but no matching frontier. The receive path also carries own-slot reserved entries independently without complete-group validation (read_state_manager.dart:285-297), contrary to the complete logical-group validation rule (docs/nips/NIP-RS.md:114). Existing tests assert preservation of override keys but not the matching frontier (mobile/test/features/channels/read_state/read_state_format_test.dart:206-233; read_state_manager_test.dart:299-383).
Author action: model carried state as validated logical groups that include the unescaped matching frontier; reserve and retain each complete frontier-plus-override group atomically. Reject malformed/partial own-slot groups. If all complete carried groups cannot fit, leave the prior slot unchanged. Add regressions for matching-frontier eviction pressure and malformed/partial own-slot groups.
Verification owner: author and Mobile CI; reviewer to mutation-check grouping and frontier retention.
Prior findings now cleared
- Encoded JSON byte accounting is fixed and has escaping-heavy coverage (
read_state_format.dart:129-149;read_state_format_test.dart:157-171). - Local persistence now prunes stale/excess
msg:/thread:entries to 1,000 and aligns context, publishable-ID, and source-time storage, with restart/hydration coverage (read_state_format.dart:37-74;read_state_manager.dart:574-619;read_state_manager_test.dart:249-297). - In-flight publish coalescing now guarantees a trailing pass after ordinary success or failure, with a parked-submit production-seam regression (
read_state_manager.dart:415-445;read_state_manager_test.dart:204-247). - Product re-validation cleared visible channel/thread marking, nested-thread isolation, selective manual-unread behavior, Activity catch-up isolation, stable dwell identity, and Android keyboard occlusion at this head.
Validation and confidence gaps
dart format --output=none --set-exit-if-changed .: pass, 662 files unchanged.flutter analyze: pass, no issues.- Mobile gateway recipe tests: pass at this exact head.
git diff --check 9322289...dd7f76d: pass. - Local focused/full Flutter tests were blocked before execution because this review machine has not accepted the Xcode 27 license (
objective_cnative-asset discovery /xcrunexit 69). This is reviewer tooling, not author rework. - Native iOS/Android lifecycle and accessibility observation was not performed. Verification owner: mobile release/QA.
- At publication,
Clients / Mobile,Mobile Swift Domain / Mobile Swift, and Codex Security Review were still running; DCO, Semgrep OSS, zizmor, and completed exact-head gates were green. Those pending checks do not remove either source-proven defect.
A DM behind a sheet or with the app inactive no longer reads a reply that loads meanwhile; it reads through it when shown again. Carried override keys now travel as complete NIP-RS groups with their frontier: retention reserves each group and its frontier together, incomplete own-slot groups are rejected whole, and local saving never prunes a group's frontier. Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
|
Thanks for the re-review. Both findings are fixed in dc56b6b. P1: covered or inactive DM. The open-read effect now runs only while the route is current and the app is in use. It uses the same Two notes on the test shape, because the obvious tests pass without the fix:
New tests: P2: override groups. Carried state is now complete NIP-RS groups:
New tests:
Local gates on dc56b6b: |
jedwards27
left a comment
There was a problem hiding this comment.
:bot: Jude’s code review agent
Re-reviewed base 932228936464a0bdd44eae0f5e95ca5bbcd819b9 through exact head dc56b6b6b57454255641e7871de7a0cd95a8a66c. The DM lifecycle defect and the prior frontier-retention defect are fixed. One author-actionable NIP-RS validation defect remains, so I do not recommend merging this head.
Finding — malformed or frontierless own-slot overrides can still be republished
Override grouping currently occurs after generic entry sanitization. decodeReadStateBlob() passes the raw map through sanitizeReadStateContexts() (mobile/lib/shared/read_state/read_state_format.dart:348-356), which discards invalid entries independently (:359-369). _carryOwnOverrides() later applies completeOverrideGroups() only to that reduced Map<String, int> (mobile/lib/shared/read_state/read_state_manager.dart:285-296).
For this valid JSON wire shape:
ctx = 100
ov_c:ctx = 7
ov_b:ctx = "invalid"
sanitization drops only ov_b:ctx, leaving {ctx: 100, ov_c:ctx: 7}. completeOverrideGroups() then accepts the surviving ov_c: as a tombstone (read_state_format.dart:106-123). NIP-RS explicitly requires collecting and validating all siblings before per-entry discard: an invalid sibling rejects the whole group (docs/nips/NIP-RS.md:101-115). An exact control-flow probe at this head reproduced the reduction and acceptance.
The same logical validation boundary permits all three counter siblings without a matching frontier. completeOverrideGroups() accepts the counters alone, while retention and _currentContexts() reserve the frontier only if present (read_state_format.dart:182-189; read_state_manager.dart:566-576). Mobile can therefore republish a frontierless override group, violating the mandatory frontier-plus-siblings co-location rule (docs/nips/NIP-RS.md:654-662). Current tests cover partial valid-valued siblings but not an invalid sibling discarded before grouping or a complete counter set with no frontier (mobile/test/features/channels/read_state/read_state_format_test.dart:273-300; read_state_manager_test.dart:299-401).
Author action: validate override groups against the raw decoded context object before generic per-entry sanitization, rejecting the entire group for any malformed or extra sibling. Model a carried logical group as counters plus its required escaped/unescaped matching frontier, and reject groups whose frontier is absent. Add production-seam manager regressions for (1) valid ov_c plus invalid ov_b and (2) complete counters without a frontier, proving neither malformed group is submitted while unrelated frontier state remains intact. Mutation-check both per-entry-first sanitization and the missing-frontier guard.
Verification owner: author and exact-head Mobile CI; reviewer to re-check protocol ordering, escaped identity, and mutations.
Cleared at this head
- DM open-read now requires a current route and resumed app state, with production widget/provider regressions for a modal sheet and app-inactive transition (
mobile/lib/features/channels/channel_detail_page.dart:544-562;mobile/test/features/channels/channel_detail_page_test.dart:2223-2315). Immediate foreground reads resume correctly. - Accepted override groups and ordinary/escaped frontiers are now retained atomically under pressure, oversized reserved state fails closed, and carried frontiers survive pruning/hydration (
read_state_format.dart:173-245;read_state_manager.dart:579-629). - Prior encoded-size, bounded-persistence, trailing-publication, visible-read, manual-unread, nested-thread, Activity, and dwell findings remain cleared by source/test trace.
Validation and confidence gaps
git diff --checkpassed. Hermit Dart formatting checked 661 files with zero changes. Source/worktrees were clean at exact head.- Author reports exact-head
just mobile-testpassing (3,082 + 3 tests). Reviewer execution reached all five gateway-recipe checks, then Flutter was blocked by this host's unaccepted Xcode 27 license (xcrunexit 69 /objective_cnative-asset discovery). This is reviewer tooling, not extra author work. - DCO, Semgrep OSS, zizmor, changed-path, and dead-token checks were green when polled.
Clients / Mobile,Mobile Swift Domain / Mobile Swift, and Codex Security Review were still pending. - No native-device lifecycle/accessibility run was performed. Verification owner: mobile release/QA.
…rontier Decoding now checks each override group on its raw values before the per-entry rule, so an invalid sibling rejects the whole group instead of leaving a false tombstone. A carried group must also travel with its frontier; a group without one is rejected. Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
|
Thanks. Both parts are fixed in 188f372. Groups before entries. Frontier required.
A group without a frontier is not carried, so it is never submitted. New manager test, through the real decode, carry and publish path:
The published slot is exactly the frontiers (including Mutations:
Three older fixtures had groups with no frontier. I added the frontiers, so they still test what they were meant to test. Local gates on 188f372: |
jedwards27
left a comment
There was a problem hiding this comment.
:bot: Jude’s code review agent
Verdict: APPROVE
Reviewed base 932228936464a0bdd44eae0f5e95ca5bbcd819b9 through exact head 188f3722b02ca3d7d534ae2d22000aadc786744b. The prior raw-group/frontier blocker is fixed, and no unresolved author-actionable defect remains.
Closure of the prior blocker
- Override counters are now collected and validated as logical groups on the raw decoded object before generic per-entry sanitization (
mobile/lib/shared/read_state/read_state_format.dart:382-415). An invalid sibling therefore rejects the whole group instead of collapsing a live group into an accepted tombstone. - Carrying a group now requires its matching frontier, with reserved raw IDs mapped to the escaped wire frontier through
overrideFrontierKey(read_state_format.dart:84-153). - Retention reserves accepted group-plus-frontier units atomically and returns
nullwhen the reserved state cannot fit (read_state_format.dart:181-268); the manager treats that as “publish nothing,” leaving the prior slot in place (read_state_manager.dart:448-477). - Persistence and hydration preserve the same complete unit across restart (
read_state_manager.dart:557-629). - The manager regression exercises the production path from encrypted raw wire data through decode, own-slot carry, and publication. It distinguishes an invalid sibling, a complete group without a frontier, and a valid complete group (
mobile/test/features/channels/read_state/read_state_manager_test.dart:403-467). The retention tests cover frontier co-location under byte pressure and fail-closed oversize behavior (read_state_format_test.dart:203-325). Restoring per-entry-first sanitization or removing the frontier guard changes the exact published map and fails these tests.
Adjacent behavior rechecked
The broader mobile read-state behavior remains consistent with the PR contract and mobile vision:
- Channel/thread automatic reads retain their 300 ms current-route/resumed-app fence, bounded bottom timestamp, selective fully-visible row marking, nested-thread isolation, and manual-unread behavior (
mobile/lib/features/channels/reading_marks.dart:12-169;channel_detail_page/message_list.dart:550-618;thread_detail_page.dart:690-758). - DM open-read remains current-route and resumed-app gated, so replies loaded under a sheet or while inactive are read only when the DM is shown again (
channel_detail_page.dart:209-224,507-562). - Activity read projection still evaluates every grouped event against channel, message, and thread frontiers (
mobile/lib/features/activity/inbox_read_state.dart:5-59). - Production-page tests cover channel catch-up/manual unread, DM open under sheet/lifecycle transitions, historical rows, direct and empty threads, and keyboard occlusion (
mobile/test/features/channels/channel_detail_page_test.dart:2110-2729).
Validation
At exact clean head 188f3722b02ca3d7d534ae2d22000aadc786744b:
git diff --check dc56b6b6b57454255641e7871de7a0cd95a8a66c..HEAD: pass.just mobile-check: pass — Dart format checked 662 files with zero changes; Flutter analyze reported no issues.just mobile-test: gateway-recipe checks passed, but Flutter test execution was blocked before tests by this review host'sobjective_cnative-asset hook becausexcruncannot return the Apple SDK path. This is reviewer tooling, not author rework.- Exact-head CI:
Clients / Mobile,Mobile,Clients / Results,Mobile Swift Domain / Mobile Swift,Mobile Swift Domain / Results, DCO, Semgrep OSS, zizmor, changed-path, and dead-token checks passed. Codex Security Review remained pending at the final poll. - Authenticated reviewer
jedwards27differs from PR authorloganj; normal approval semantics apply.
Author action: none.
Verification owner: the Codex Security Review gate owns its pending result; reviewer/tooling owns the local Xcode/native-asset gap; mobile release/dogfood owns optional native iOS/Android geometry and lifecycle observation.
Residual risk: focused/full Flutter tests and physical-device lifecycle/accessibility behavior were not independently observed on this host. Exact-head mobile CI and production-seam widget coverage are green; these remaining confidence gaps do not establish an author-actionable defect.
jedwards27
left a comment
There was a problem hiding this comment.
:bot: Jude’s code review agent
Reviewed base 932228936464a0bdd44eae0f5e95ca5bbcd819b9 through exact head 188f3722b02ca3d7d534ae2d22000aadc786744b. APPROVE — no author-actionable defects remain.
The final delta closes the remaining NIP-RS validation blocker: raw override siblings are grouped before sanitization, malformed groups are rejected wholesale, a matching frontier (including escaped identity) is mandatory, complete group-plus-frontier units are retained atomically and fail closed when they cannot fit, and persistence/hydration preserve the same unit across restart. Production-seam and pressure tests bind malformed-sibling, frontierless-group, valid-group, and retention behavior (mobile/lib/shared/read_state/read_state_format.dart:84-153,181-268,382-415; mobile/lib/shared/read_state/read_state_manager.dart:448-477,557-629; mobile/test/features/channels/read_state/read_state_manager_test.dart:403-467; mobile/test/features/channels/read_state/read_state_format_test.dart:203-325).
The product/mobile lane also found no remaining defect: current-route and resumed-lifecycle fencing for DMs, immediate foreground open-read, 300 ms visible/catch-up semantics, manual-unread preservation, Activity projection, and production widget seams are sound. Prior findings covering encoded-size accounting, bounded/aligned persistence, trailing publication, override integrity, and DM lifecycle are cleared at this head.
Validation at the reviewed head:
- Exact-head
Clients / Mobile,Clients / Results,Mobile,Mobile Swift Domain / Mobile Swift, andMobile Swift Domain / Resultspassed, as did DCO, Semgrep OSS, zizmor, changed-path, and dead-token checks. - Required checks were terminal and non-failing immediately before submission.
Run Codex Security Reviewremained in progress but is not a required gate. - Reviewer worktrees were clean;
just mobile-check, formatting/analyze, andgit diff --checkpassed.
Confidence gaps, not author rework: focused local Flutter execution is blocked by this host’s unaccepted Xcode license; exact-head Mobile CI supplies the package gate. No physical-device iOS/Android lifecycle/accessibility observation was performed; mobile release/QA owns that verification. The uncapped future-dated DM timestamp remains cross-client hardening debt rather than a defect introduced by this PR.
🤖
Summary
When you read a channel or thread on the phone, the web and desktop app (buzz-app) should show it as read too, and the reverse. buzz-app no longer saves a "read" mark for each ordinary message. It saves one "caught up to here" mark per channel and per thread, and keeps per-message marks only for mentions, DMs, broadcasts and replies. Mobile already reads those marks. This PR makes mobile write them, so reading on the phone clears unread on other devices and saved read state stays small.
What changes on the phone:
Related issue
None found. buzz-app makes the same DM and manual-unread changes in block/buzz-app#616.
Testing
Details
Read marks. "Caught up" is saved as
activity:<channel>for a channel andthread-activity:<root>for a thread. A caught-up mark reads only ordinary top-level messages (channel) or that thread's replies (thread). A per-message mark ismsg:<id>.Caught-up time. The mark is set to the time of the bottom row, and never later than the current time. The time of a newer reply is not used. So a top-level message that arrives late, with an earlier timestamp, stays unread. A clock that runs fast, on this phone or the sender's, cannot mark messages read before they arrive.
Nested threads. A thread opened on a nested reply shows only one branch. Reaching its end writes per-message marks only, so unread messages in other branches stay unread.
Reading delay. The 300 ms reading delay now restarts only when messages arrive or leave. Before, any page rebuild restarted it, for example a typing indicator.
Activity rows. A row that groups several events is done only when each event is read. Each event is checked against the channel mark, its own
msg:mark, and its thread marks if it is a reply. Opening a row goes to the oldest unread event.Publishing. Mobile no longer republishes per-message marks it got from other devices. buzz-app removes those once a caught-up mark covers them. Broad marks (channel, thread, caught-up) are still republished, so they stay available after the fetch window. The published blob is capped at buzz-app's 40 KiB limit. Channel marks are kept first, then thread marks, then caught-up marks, then the newest per-message marks. Before, a blob over the encryption limit (about 64 KiB) turned off sync for the rest of the session.
DMs and manual unread. Opening a DM reads it through the newest loaded message, replies included, as mobile does today. Reaching the bottom of a channel ends a manual unread. Manual unread on mobile is local to this session, so ending it does not change what other devices see.
Compatibility. Older desktop builds read only channel and per-message marks. They may show extra unread for channels read on the phone.
Follow-ups, not in this PR