element-hq / element-hq/element-meta
Fable driven long standing app issue fixes
- Dominant language
- No language data
- Stars
- 112
- Forks
- 26
- Avg merge
- 6h 6m
- Merged PRs (30d)
- 4
Description
Matthew has been running Fable on Element X iOS and the RustSDK, having it fix long standing and hard to track issues. The point to this ticket is to gather up all [his original findings](https://github.com/element-hq/element-x-ios/blob/matthew/preview-prefill/docs/dogfood-branches.md) as a tasklist so we can deliver them following normal planning, PR and review procedures.
Some might prove correct, others not or having taken the wrong approach. What's important here is that they have empirically fixed real issues and they are valuable as a starting point, which would have otherwise taken a long time to find.
The full list and description is available at https://github.com/element-hq/element-x-ios/blob/matthew/preview-prefill/docs/dogfood-branches.md
Triage tags: `perf` · `bug:critical` (deadlocks, crashes/panics, data or message loss) · `bug:major` (wrong or stuck UI, misordered/missing messages, stale state) · `bug:minor` (incorrect but low impact) · `polish` (animation/cosmetic) · `qol` (small behaviour improvement) · `feature` · `diag` (logging/diagnostics: keep or strip, noted per item) · `infra` (tests/mocks/tooling) · `revert`/`dead-end` (net-zero pairs, listed so no commit is lost).
## matthew/sss-roomlist-ordering — stable room-list ordering
### SDK
- [x] `bug:major` Room resubscription cancels the in-flight request and refreshes settings of already-subscribed rooms [`c99b69226`](https://github.com/matrix-org/matrix-rust-sdk/commit/c99b69226), [`66654fbad`](https://github.com/matrix-org/matrix-rust-sdk/commit/66654fbad).
Fixed in https://github.com/matrix-org/matrix-rust-sdk/pull/6828
- [ ] `bug:major` Order the room list atomically by timestamp, slotting rooms without one by bump stamp [`029ce3280`](https://github.com/matrix-org/matrix-rust-sdk/commit/029ce3280)
- [ ] `bug:major` Stop the latest-event candidate scan at the first gap (no more ancient decryptable-event timestamps) [`04032e107`](https://github.com/matrix-org/matrix-rust-sdk/commit/04032e107)
- [ ] `bug:major` Stop inconsistent anchors promoting previewless rooms [`b92ec4b4c`](https://github.com/matrix-org/matrix-rust-sdk/commit/b92ec4b4c) (same patch re-applied on the startup-time lineage as [`51d158418`](https://github.com/matrix-org/matrix-rust-sdk/commit/51d158418))
- [ ] `bug:minor` Don't synthesise `now()` timestamps for invites [`e8cf882bb`](https://github.com/matrix-org/matrix-rust-sdk/commit/e8cf882bb) — possibly fixes [#4916](https://github.com/element-hq/element-x-ios/issues/4916)
- [x] `bug:minor` Replay current state when an FFI state listener attaches (prerequisite for eager sync start) [`a804ac7fc`](https://github.com/matrix-org/matrix-rust-sdk/commit/a804ac7fc)
Fixed (and more) in https://github.com/matrix-org/matrix-rust-sdk/pull/6895
### EXI
- [x] `perf` Skip re-applying an identical room list filter [`2df7d3209`](https://github.com/element-hq/element-x-ios/commit/2df7d3209)
Fixed in https://github.com/element-hq/element-x-ios/pull/6016
- [x] `bug:critical` Drop tracing spans whose `defer exit()` runs off-thread (rustPanic → abort at launch) [`0c42ffd0e`](https://github.com/element-hq/element-x-ios/commit/0c42ffd0e)
Fixed in https://github.com/element-hq/element-x-ios/pull/5996
## matthew/startup-time — launch speed
> Warm relaunch, 300-room account: 2.13s → 1.33s (sim Debug), ~1.1s wall device Release.
### SDK
- [ ] `perf` Cut needless work/requests from session restore (local account data, cached OIDC metadata) [`b8e28420e`](https://github.com/matrix-org/matrix-rust-sdk/commit/b8e28420e), write-through [`015809e4f`](https://github.com/matrix-org/matrix-rust-sdk/commit/015809e4f)
- [ ] `bug:minor` server truth for auto-enable-backups [`4fdf6af78`](https://github.com/matrix-org/matrix-rust-sdk/commit/4fdf6af78)
⚠️ **Reopened: was ticked as "Fixed in EXI#6045", but that PR only removes EXI's init-time `updateBackupStateFromRemote` call (the [`270633527`](https://github.com/element-hq/element-x-ios/commit/270633527) item below).** This is an SDK change (`should_auto_enable_backups` must fetch the account data rather than trust the store, which [`b8e28420e`](https://github.com/matrix-org/matrix-rust-sdk/commit/b8e28420e) stops pre-fetching), it is not on SDK main, and it has to travel with [`b8e28420e`](https://github.com/matrix-org/matrix-rust-sdk/commit/b8e28420e)
- [ ] `perf` Skip the store-cipher KDF for high-entropy passphrases [`5b7c3240d`](https://github.com/matrix-org/matrix-rust-sdk/commit/5b7c3240d); EXI adopts it by declaring the passphrase high-entropy [`04f9f87da`](https://github.com/element-hq/element-x-ios/commit/04f9f87da)
Relates to and improves https://github.com/matrix-org/matrix-rust-sdk/pull/6581/changes
Handle in https://github.com/matrix-org/matrix-rust-sdk/pull/6878
- [ ] `perf` Slim single-call `RoomSummaryDetails` FFI [`4966f5403`](https://github.com/matrix-org/matrix-rust-sdk/commit/4966f5403)
- [ ] `perf` Persist latest-event values in a second phase, batching room-list updates [`bdcc1b1fd`](https://github.com/matrix-org/matrix-rust-sdk/commit/bdcc1b1fd)
- [ ] `bug:minor` Compute latest events for rooms created by the response being processed [`17b6dc3b7`](https://github.com/matrix-org/matrix-rust-sdk/commit/17b6dc3b7)
### EXI
- [ ] `perf` Build room summaries from the slim FFI with bounded concurrency [`e320d27b8`](https://github.com/element-hq/element-x-ios/commit/e320d27b8), [`3aa4ba8f7`](https://github.com/element-hq/element-x-ios/commit/3aa4ba8f7)
- [x] `perf` Defer alternate/static room summary providers until the primary has published [`31693e404`](https://github.com/element-hq/element-x-ios/commit/31693e404)
Handled in https://github.com/element-hq/element-x-ios/pull/6038
- [x] `perf` Drop `SecureBackupController`'s unconditional init-time remote backup check [`270633527`](https://github.com/element-hq/element-x-ios/commit/270633527)
Fixed in https://github.com/element-hq/element-x-ios/pull/6045
- [ ] `perf` Start session restore eagerly from `AppCoordinator.init` on a detached task, build+start sync there [`a9d03e576`](https://github.com/element-hq/element-x-ios/commit/a9d03e576), [`6318ba1e1`](https://github.com/element-hq/element-x-ios/commit/6318ba1e1), [`eabed5558`](https://github.com/element-hq/element-x-ios/commit/eabed5558)
- [x] ~~`perf` Defer Sentry/analytics/notification startup off the critical path~~
Not wanted, it makes us blind during app startup [`8c33808c9`](https://github.com/element-hq/element-x-ios/commit/8c33808c9), [`505a9d6da`](https://github.com/element-hq/element-x-ios/commit/505a9d6da)
- [x] `bug:minor` Fix empty-list flash [`505a9d6da`](https://github.com/element-hq/element-x-ios/commit/505a9d6da)
Fixed in https://github.com/element-hq/element-x-ios/pull/6045
- [x] `perf` Consume the room summary provider's current state synchronously at init [`3a85fab72`](https://github.com/element-hq/element-x-ios/commit/3a85fab72)
Handled in https://github.com/element-hq/element-x-ios/pull/6045
- [ ] `infra` Regenerate the SDK mocks against the local preview-prefill bindings [`213cbe3e8`](https://github.com/element-hq/element-x-ios/commit/213cbe3e8) (housekeeping; re-done for each FFI change, e.g. [`d5e0dd384`](https://github.com/element-hq/element-x-ios/commit/d5e0dd384))
## matthew/preview-prefill — instant previews, timelines, push-taps
> Headline: cold launch to a correct room list ~200ms on a 6k-room account (was ~2.1s). Answers [#4102 Launch time is ~20x slower than it should be](https://github.com/element-hq/element-x-ios/issues/4102).
### SDK — previews and the back-pagination queue
- [ ] `feature` Accept undecrypted events as latest-event candidates, replaced in place on key arrival [`e1374ff8f`](https://github.com/matrix-org/matrix-rust-sdk/commit/e1374ff8f), [`1457a8eca`](https://github.com/matrix-org/matrix-rust-sdk/commit/1457a8eca)
- [ ] `feature` Valueless rooms backfill previews automatically; viewport rooms preload two-tier [`25c7c3a83`](https://github.com/matrix-org/matrix-rust-sdk/commit/25c7c3a83), [`c55c20607`](https://github.com/matrix-org/matrix-rust-sdk/commit/c55c20607), [`3685da91e`](https://github.com/matrix-org/matrix-rust-sdk/commit/3685da91e) — fixes [#4898](https://github.com/element-hq/element-x-ios/issues/4898), [#5189](https://github.com/element-hq/element-x-ios/issues/5189)
- [ ] `perf` Back-pagination queue tuning on top of Stefan's series: per-priority concurrency caps [`6a3546d95`](https://github.com/matrix-org/matrix-rust-sdk/commit/6a3546d95), default concurrency 6 [`993bb82d7`](https://github.com/matrix-org/matrix-rust-sdk/commit/993bb82d7) — part of [sdk#6014](https://github.com/matrix-org/matrix-rust-sdk/issues/6014)
- [ ] `perf` Drain the latest-event backlog in reverse chronological order [`32220d70e`](https://github.com/matrix-org/matrix-rust-sdk/commit/32220d70e)
- [ ] `bug:major` Stop automatic backfill walking a room's entire history (origin-aware re-arm, strict budget) [`53c1bed53`](https://github.com/matrix-org/matrix-rust-sdk/commit/53c1bed53) — likely fixes [#3183](https://github.com/element-hq/element-x-ios/issues/3183)
- [ ] `bug:major` Never fetch `/room_keys/version` while classifying UTDs (blocked first paint up to 49s offline) [`12e2d0d8b`](https://github.com/matrix-org/matrix-rust-sdk/commit/12e2d0d8b)
- [ ] `bug:major` Follow-up (rageshake 7515, round 26): with nothing ever asking the server, the cached-only answer stayed unknown and every pre-device UTD was labelled "Historical messages are not available on this device" even with backup enabled; unknown now defaults to "exists" when backups are enabled on this device [`3fef1e98e`](https://github.com/matrix-org/matrix-rust-sdk/commit/3fef1e98e), and the cache is warmed on demand (not at setup, which broke ten backups tests) [`134f727b8`](https://github.com/matrix-org/matrix-rust-sdk/commit/134f727b8). Upstream only together with [`12e2d0d8b`](https://github.com/matrix-org/matrix-rust-sdk/commit/12e2d0d8b)
- [ ] `bug:major` Stop room-list spasming during catch-up syncs (one atomic reorder per drain) [`a637111d1`](https://github.com/matrix-org/matrix-rust-sdk/commit/a637111d1), [`f08c09150`](https://github.com/matrix-org/matrix-rust-sdk/commit/f08c09150) — fixes [#4814](https://github.com/element-hq/element-x-ios/issues/4814), [rageshake#4993](https://github.com/element-hq/element-x-ios-rageshakes/issues/4993)
### SDK — sync correctness
- [x] `bug:critical` Take latest-events work out of the `state_store_lock` region; surface stuck response handling [`f8c5742fa`](https://github.com/matrix-org/matrix-rust-sdk/commit/f8c5742fa), [`d80633f6f`](https://github.com/matrix-org/matrix-rust-sdk/commit/d80633f6f)
- [ ] `diag` Step markers through response handling + FFI debug defaults for sliding sync / latest events / back-pagination queue so a parked await names its stage [`a471b0a01`](https://github.com/matrix-org/matrix-rust-sdk/commit/a471b0a01), [`f620707ca`](https://github.com/matrix-org/matrix-rust-sdk/commit/f620707ca) (keep)
- [ ] `bug:major` Room-list catch-up: publish state at round start, stale indicator, don't cancel rounds, defer bulky extensions [`455cf9dc8`](https://github.com/matrix-org/matrix-rust-sdk/commit/455cf9dc8), [`d44e4482f`](https://github.com/matrix-org/matrix-rust-sdk/commit/d44e4482f), [`f40199ed4`](https://github.com/matrix-org/matrix-rust-sdk/commit/f40199ed4), [`f17362d62`](https://github.com/matrix-org/matrix-rust-sdk/commit/f17362d62)
- [ ] `revert` Experiment: wildcard `required_state` only for subscribed rooms [`ec61a62f9`](https://github.com/matrix-org/matrix-rust-sdk/commit/ec61a62f9), reverted [`4b18f34a4`](https://github.com/matrix-org/matrix-rust-sdk/commit/4b18f34a4) (net zero)
- [ ] `bug:critical` Never persist the sliding-sync `pos` ahead of the event cache (ack-gated) [`a2ce71d71`](https://github.com/matrix-org/matrix-rust-sdk/commit/a2ce71d71) — fixes [#4729](https://github.com/element-hq/element-x-ios/issues/4729), addresses [sdk#6401](https://github.com/matrix-org/matrix-rust-sdk/issues/6401), likely [rageshake#6243](https://github.com/element-hq/element-x-ios-rageshakes/issues/6243), [rageshake#6272](https://github.com/element-hq/element-x-ios-rageshakes/issues/6272)
- [ ] `perf` Make the read-receipt hunt a shallow seek, then merge same-room walks into shared runs [`8b9058252`](https://github.com/matrix-org/matrix-rust-sdk/commit/8b9058252), [`00c64393e`](https://github.com/matrix-org/matrix-rust-sdk/commit/00c64393e)
### SDK — NSE and push-taps
- [ ] `feature` Ingest notification-fetched events into the shared event cache [`48e010cbc`](https://github.com/matrix-org/matrix-rust-sdk/commit/48e010cbc)
- [ ] `bug:major` The `/context` fallback (the common path on a slow server) persisted nothing, so a pushed message took ~40s to show after opening the app (round 18); now persisted as a limited batch at the room's tail unless the store already holds something newer [`d00a5b4d6`](https://github.com/matrix-org/matrix-rust-sdk/commit/d00a5b4d6)
- [x] `perf` Serve focused timelines from the persisted event cache instead of always hitting `/context` [`8b73bb326`](https://github.com/matrix-org/matrix-rust-sdk/commit/8b73bb326)
- [ ] `bug:major` Upgrade the persisted UTD in place once the NSE's encryption sync fetches the key [`6cf6b06e5`](https://github.com/matrix-org/matrix-rust-sdk/commit/6cf6b06e5)
- [ ] `bug:minor` Don't back-paginate rooms we're not joined to [`2860034b0`](https://github.com/matrix-org/matrix-rust-sdk/commit/2860034b0)
- [ ] `perf` Stop the NSE burning its ~30s budget on fixed network costs (cached `/versions`, no device-list refresh, single `/sync` attempt) [`c187fef45`](https://github.com/matrix-org/matrix-rust-sdk/commit/c187fef45)
### SDK — launch speed and diagnosability
- [ ] `perf` Take the WAL checkpoint off the store-open critical path (deferred PASSIVE checkpoint) [`c4b51714a`](https://github.com/matrix-org/matrix-rust-sdk/commit/c4b51714a)
- [ ] `diag` Time the state/crypto store opens and WAL checkpoints [`813568789`](https://github.com/matrix-org/matrix-rust-sdk/commit/813568789); log all SQLite stores at debug in the FFI tracing filter [`000ee3710`](https://github.com/matrix-org/matrix-rust-sdk/commit/000ee3710) (keep)
- [ ] `diag` Give every first-party SDK crate a root log target [`3524b89a1`](https://github.com/matrix-org/matrix-rust-sdk/commit/3524b89a1)
- [ ] `perf` Load rooms progressively in pages of 200, with a rooms-loaded barrier [`a6bdc2720`](https://github.com/matrix-org/matrix-rust-sdk/commit/a6bdc2720)
- [ ] `bug:minor` Wait for progressive room load before snapshotting the room list [`110050e5a`](https://github.com/matrix-org/matrix-rust-sdk/commit/110050e5a)
- [ ] `perf` Load most recent rooms first (`recency` column) and fill the rest concurrently [`8d22c6b4d`](https://github.com/matrix-org/matrix-rust-sdk/commit/8d22c6b4d)
- [ ] `diag` Log which event counts a room as unread [`24333794e`](https://github.com/matrix-org/matrix-rust-sdk/commit/24333794e)
- [ ] `perf` Read only the profile row for latest-event preview senders (was seven store queries per room) [`b9e04f7d4`](https://github.com/matrix-org/matrix-rust-sdk/commit/b9e04f7d4)
- [ ] `perf` Shrink the inline room load to 64 rooms [`79b5182d3`](https://github.com/matrix-org/matrix-rust-sdk/commit/79b5182d3), [`4df4dbca2`](https://github.com/matrix-org/matrix-rust-sdk/commit/4df4dbca2), [`2cd2f16b4`](https://github.com/matrix-org/matrix-rust-sdk/commit/2cd2f16b4)
- [ ] `perf` Take the media retention trim off the `clear_caches` critical path (~20s of silent sqlite work on the splash) and time the remaining steps [`09ad86278`](https://github.com/matrix-org/matrix-rust-sdk/commit/09ad86278)
### EXI — prefill rooms via the auto-pagination engine, not SSS subscriptions
- [ ] `feature` Preload visible rooms via the back-pagination queue (1 visible event requested, SDK tops up) [`38ef09140`](https://github.com/element-hq/element-x-ios/commit/38ef09140), [`c10a36027`](https://github.com/element-hq/element-x-ios/commit/c10a36027)
- [x] ~~`dead-end` Route visible rooms through a dedicated viewport sliding-sync connection~~ [`eadcb4239`](https://github.com/element-hq/element-x-ios/commit/eadcb4239), [`5cd7d67b5`](https://github.com/element-hq/element-x-ios/commit/5cd7d67b5) — **dead end, backed out later**: switched back to growing `all_rooms` in blocks of 200 with `timeline_limit=1` and hooked up autopagination instead
### EXI — offline mode
- [x] `bug:major` Never block launch on the network (fire-and-forget `auth_metadata` caching); add per-launch `LaunchMetrics` [`e86a457b5`](https://github.com/element-hq/element-x-ios/commit/e86a457b5)
Fixed in https://github.com/element-hq/element-x-ios/pull/6019 (the `cacheAccountURL` half; the `LaunchMetrics` instrumentation in the same commit is `diag` and not upstreamed, team's call whether it is wanted)
### EXI — spurious "join" screens for unsynced rooms
- [ ] `bug:major` Never show a join screen for a room the user is already in [`ac2169469`](https://github.com/element-hq/element-x-ios/commit/ac2169469) — fixes [#4287](https://github.com/element-hq/element-x-ios/issues/4287), likely [rageshake#7352](https://github.com/element-hq/element-x-ios-rageshakes/issues/7352), [rageshake#2479](https://github.com/element-hq/element-x-ios-rageshakes/issues/2479)
### EXI — hide skeletons
- [ ] `qol` Hold the splash until the cached room list has published (zero skeleton frames) [`5ae04e03f`](https://github.com/element-hq/element-x-ios/commit/5ae04e03f)
- [ ] `qol` Don't show a Loading modal over the splash while clearing the cache [`0f12c1edd`](https://github.com/element-hq/element-x-ios/commit/0f12c1edd)
- [ ] `qol` Slow-launch feedback (round 16/22): a ">1s" Loading modal [`15e7a42e0`](https://github.com/element-hq/element-x-ios/commit/15e7a42e0), then skeletons instead of the splash gate [`da464bc52`](https://github.com/element-hq/element-x-ios/commit/da464bc52) — reverted [`0d25059a3`](https://github.com/element-hq/element-x-ios/commit/0d25059a3) after the launch re-benchmark showed rooms at ~350ms instead of ~200ms (summary build competing with the home screen mount); final form: skeletons drawn on the splash itself after 500ms of session restore, which also covers store migrations [`4dfa443d3`](https://github.com/element-hq/element-x-ios/commit/4dfa443d3)
### EXI — build provenance
- [ ] `qol` Show app and SDK git SHAs in the Settings version footer (`AppGitSHA`, `-dirty`) [`48cba7c70`](https://github.com/element-hq/element-x-ios/commit/48cba7c70)
- [ ] `infra` Point the placeholder rageshake URL at the real submit endpoint so local dev builds can file bug reports [`fcbb22570`](https://github.com/element-hq/element-x-ios/commit/fcbb22570)
### EXI — smooth infinite scrolling on the room list
- [x] `perf` Prefetch the next page half a page early [`ab7b063c7`](https://github.com/element-hq/element-x-ios/commit/ab7b063c7)
Handled in https://github.com/element-hq/element-x-ios/pull/6045
- [ ] `perf` Publish the visible range live while scrolling, grow on an unthrottled subscriber with a growth guard [`5859d1c52`](https://github.com/element-hq/element-x-ios/commit/5859d1c52); don't grow on the initial empty range [`5e55f53e9`](https://github.com/element-hq/element-x-ios/commit/5e55f53e9)
⚠️ **Split out and reopened: EXI#6045 carries the half-page prefetch and an unthrottled grow, but keeps the `isScrolling` guard, so the mid-scroll range publishing that lets the prefetch fire before the user hits the bottom is not upstream.** Verify on a long scroll before closing
- [ ] `bug:major` Don't blank the list into "no chats" when a session expiry reports a nil count [`c2d9cf778`](https://github.com/element-hq/element-x-ios/commit/c2d9cf778)
- [ ] `bug:minor` Re-snap to the real top after a system scroll-to-top [`877f5db4c`](https://github.com/element-hq/element-x-ios/commit/877f5db4c)
- [ ] `perf` Shrink the home list's first page from 100 to 64 rooms; log summary builds over 25ms [`edb009314`](https://github.com/element-hq/element-x-ios/commit/edb009314), [`babf62b7d`](https://github.com/element-hq/element-x-ios/commit/babf62b7d)
- [ ] `diag` `SummaryBuild:` per-batch timing split (FFI vs main-actor hop) [`ab4232f14`](https://github.com/element-hq/element-x-ios/commit/ab4232f14) (strip before upstreaming)
### EXI — lazyload MapLibre (480ms → 80ms on iPhone 12)
- [x] `perf` Load MapLibre lazily via dlopen (MapLibreShim framework + thin MapInterface) [`e80dd55a5`](https://github.com/element-hq/element-x-ios/commit/e80dd55a5)
Fixed in https://github.com/element-hq/element-x-ios/pull/6012
### EXI — long-tap-to-peek on the room list
- [ ] `feature` Long-press a room to peek at its timeline read-only, no read receipt; tap-to-open via `UIContextMenuInteraction`, 20% scrim, non-lazy newest-40 stack [`bec5fcbfb`](https://github.com/element-hq/element-x-ios/commit/bec5fcbfb), [`d968695b3`](https://github.com/element-hq/element-x-ios/commit/d968695b3), [`5e4f9ef82`](https://github.com/element-hq/element-x-ios/commit/5e4f9ef82), [`a89b11bda`](https://github.com/element-hq/element-x-ios/commit/a89b11bda) — implements [#3658](https://github.com/element-hq/element-x-ios/issues/3658)
### EXI — timelines stuck with one message
- [x] `bug:major` Re-run the timeline's viewport fill check after each snapshot applies [`c1cae2c9c`](https://github.com/element-hq/element-x-ios/commit/c1cae2c9c) — likely fixes [#5817](https://github.com/element-hq/element-x-ios/issues/5817) / [sdk#6709](https://github.com/matrix-org/matrix-rust-sdk/issues/6709)
Fixed in https://github.com/element-hq/element-x-ios/pull/6056
### EXI — non-blocking invite rejection
- [ ] `qol` Make declining an invite non-blocking (forget-room took ~5s behind a modal) [`556982912`](https://github.com/element-hq/element-x-ios/commit/556982912) — fixes the blocking half of [#2535](https://github.com/element-hq/element-x-ios/issues/2535), related [rageshake#6668](https://github.com/element-hq/element-x-ios-rageshakes/issues/6668)
### Accepted invite lingers in the room list (SDK, upstream bug)
- [ ] `bug:major` A long-poll in flight when Accept is tapped lands post-join carrying `invite_state`, and the MSC4186 processor's unconditional `mark_as_invited` regresses the locally Joined room back to Invited (decline has the mirror hole); in-memory `membership_from_local_action` marker makes the processor ignore `invite_state` until the action's sync echo [`f7161bf4b`](https://github.com/matrix-org/matrix-rust-sdk/commit/f7161bf4b) (+2 regression tests). Same stale-long-poll family as the event-cache bugs below
### Redactions
- [x] `feature` Route remote-target redactions through the send queue so they local echo ([`4366a2e7b`](https://github.com/matrix-org/matrix-rust-sdk/commit/4366a2e7b)) — fixes [#1713](https://github.com/element-hq/element-x-ios/issues/1713)
- [x] `infra` Re-mock `test_redact_message` / `test_redact_local_sent_message` for local-echo semantics ([`a0dbb30ba`](https://github.com/matrix-org/matrix-rust-sdk/commit/a0dbb30ba))
- [ ] `bug:minor` Long-press on a redacted message no longer shows a blank sheet; keep copy-permalink, don't present an empty menu [`d8b529726`](https://github.com/element-hq/element-x-ios/commit/d8b529726)
- [ ] `bug:minor` Reply previews of a redacted message named the redactor as its sender (round 19): the placeholder now takes sender/profile/timestamp from the redacted event's item or the reply's loaded details [`9e85204c8`](https://github.com/matrix-org/matrix-rust-sdk/commit/9e85204c8) — upstream bug, straight port
### Blocked on "Loading…" when opening rooms
- [ ] `bug:critical` THE SYNC WEDGE root-caused and fixed: snapshot cheap clone handles and release the rooms-map lock before awaiting per-room locks ([`830f3dc0e`](https://github.com/matrix-org/matrix-rust-sdk/commit/830f3dc0e)) — likely cause of [rageshake#6487](https://github.com/element-hq/element-x-ios-rageshakes/issues/6487), [#6322](https://github.com/element-hq/element-x-ios-rageshakes/issues/6322), [#7173](https://github.com/element-hq/element-x-ios-rageshakes/issues/7173), [#5716](https://github.com/element-hq/element-x-ios-rageshakes/issues/5716)
- [ ] Open question: an uncancellable "Loading…" modal that can lock the user out is a footgun — worth a design decision
### Event cache StateLock wedge: every room open stuck on a Loading modal (SDK, rageshake 7468)
- [ ] `diag` Holder-attributed stall diagnostics: every StateLock guard registers its holder span; any acquisition stalled >10s logs an `error!` naming the live holders ([`25ff0e827`](https://github.com/matrix-org/matrix-rust-sdk/commit/25ff0e827)). Caught the deadlock on its first recurrence; keep
- [x] `bug:critical` DEADLOCK root-caused and fixed: `aggregate_timeline_for_threads` held the room read guard by value and the redaction full-search re-entered the global StateLock via `ThreadEventCache::find_event` (nested read parked behind a queued backfill writer); the aggregator now takes short-lived guards per lookup ([`addbff009`](https://github.com/matrix-org/matrix-rust-sdk/commit/addbff009)). Rule: never re-acquire the StateLock under any of its guards. Nesting audit found no other deadlock site
- [ ] `bug:major` STALL hazard (on `origin/main` verbatim): the event-focused cache ran `/context`, `/messages` and `/relations` under the held global write guard, freezing every room for up to the HTTP timeout on bad network; fetch unlocked, lock to install, recovery `reload` no longer touches the network ([`5f715227a`](https://github.com/matrix-org/matrix-rust-sdk/commit/5f715227a))
- [ ] `bug:major` Thread root stuck on a loading skeleton instead of its summary: `compute_thread_summary` lost `latest_reply` when only aggregations were in memory (fresh cache or limited-sync shrink); falls back to the store's replies ([`5a4990bc3`](https://github.com/matrix-org/matrix-rust-sdk/commit/5a4990bc3)) — upstream bug
### "Loading…" locking the app on push-taps over bad network
- [ ] `bug:major` Let a tap on a route's "Loading…" modal abandon the navigation (event/child-event routes, ChatsTab alias resolution, thread presentation) [`5c11e1b37`](https://github.com/element-hq/element-x-ios/commit/5c11e1b37)
### Permalink positioning
- [ ] `bug:minor` Focus scrolls put the top of the target message halfway up the viewport, re-measured as layout settles [`6b300c22c`](https://github.com/element-hq/element-x-ios/commit/6b300c22c) — fixes [#2806](https://github.com/element-hq/element-x-ios/issues/2806), possibly [#4377](https://github.com/element-hq/element-x-ios/issues/4377)
### Tap-on-push goes straight to the right room/permalink
- [ ] `bug:major` Focus notification taps on their event; bounded wait for session restore in background refresh [`580ba004d`](https://github.com/element-hq/element-x-ios/commit/580ba004d) — fixes [#4790](https://github.com/element-hq/element-x-ios/issues/4790)
- [ ] `bug:major` Route taps on notifications the NSE couldn't process (raw pusher payload) [`eb0555a53`](https://github.com/element-hq/element-x-ios/commit/eb0555a53) — likely fixes [rageshake#5207](https://github.com/element-hq/element-x-ios-rageshakes/issues/5207)
- [ ] `qol` Open the room live at the bottom when the tap targets the newest message [`a39307fc0`](https://github.com/element-hq/element-x-ios/commit/a39307fc0), wait for live items [`f26581fe4`](https://github.com/element-hq/element-x-ios/commit/f26581fe4), retract the focus toast [`e550a82df`](https://github.com/element-hq/element-x-ios/commit/e550a82df), compare against latest-events via new `latestEventId` FFI ([`92950c9f3`](https://github.com/matrix-org/matrix-rust-sdk/commit/92950c9f3)) [`5e43d0872`](https://github.com/element-hq/element-x-ios/commit/5e43d0872) — matches [rageshake#3004](https://github.com/element-hq/element-x-ios-rageshakes/issues/3004)
- [ ] `bug:major` Attach the `UNUserNotificationCenter` delegate at init (deferred startup dropped kill-state push routes) [`2c7928bb9`](https://github.com/element-hq/element-x-ios/commit/2c7928bb9)
- [ ] `bug:major` Decide live-vs-focus at the route level from the fetched event's ID *and* timestamp [`363b3b7f5`](https://github.com/element-hq/element-x-ios/commit/363b3b7f5), new `latestEventTimestamp` FFI ([`9a7707b37`](https://github.com/matrix-org/matrix-rust-sdk/commit/9a7707b37)) — likely fixes [rageshake#2349](https://github.com/element-hq/element-x-ios-rageshakes/issues/2349)
- [ ] `bug:major` Regression fixed (rageshake 7527, round 26): the skip applied to every event route, so a permalink to a room's newest message opened the room with no highlight; `openLiveIfNewest` is now set only by notification taps [`2707df925`](https://github.com/element-hq/element-x-ios/commit/2707df925) (+2 RoomFlowCoordinator tests). Upstream only together with [`363b3b7f5`](https://github.com/element-hq/element-x-ios/commit/363b3b7f5)
- [ ] `perf` Scope dirty-lock recovery to the rooms other processes actually touched (was 5451 rooms / ~11s inside the tap's room open) ([`b10561742`](https://github.com/matrix-org/matrix-rust-sdk/commit/b10561742), [`41e4704ed`](https://github.com/matrix-org/matrix-rust-sdk/commit/41e4704ed)) — advances [sdk#4874](https://github.com/matrix-org/matrix-rust-sdk/issues/4874), [sdk#6681](https://github.com/matrix-org/matrix-rust-sdk/issues/6681); likely fixes [rageshake#5029](https://github.com/element-hq/element-x-ios-rageshakes/issues/5029)
### Jump straight to the thread from a room preview
- [ ] `feature` Open the thread when a room's preview shows a threaded reply [`b2140c102`](https://github.com/element-hq/element-x-ios/commit/b2140c102), new `latestEventThreadRootId` FFI ([`0ba9d0d9d`](https://github.com/matrix-org/matrix-rust-sdk/commit/0ba9d0d9d))
- [ ] `bug:minor` Routing silently broke when the threaded reply was EDITED (latest event = the `m.replace`); the latest-event value now carries a thread root resolved through edits, FFI made async ([`b69d40ecd`](https://github.com/matrix-org/matrix-rust-sdk/commit/b69d40ecd)), mocks regenerated [`d5e0dd384`](https://github.com/element-hq/element-x-ios/commit/d5e0dd384)
- [ ] `perf` [`b69d40ecd`](https://github.com/matrix-org/matrix-rust-sdk/commit/b69d40ecd) resolved the edit's original via `Room::event_cache()`, creating room caches under the global write lock on the launch page (first 64-room summary page 30ms → 250ms, round 22); read it from the store instead ([`a14651a2d`](https://github.com/matrix-org/matrix-rust-sdk/commit/a14651a2d)). Upstream only together
- [ ] `qol` Room-list rows show a Compound threads icon before the preview when the previewed event is in a thread (as Element Web) [`51e1d46b0`](https://github.com/element-hq/element-x-ios/commit/51e1d46b0), position nudge [`a85914f67`](https://github.com/element-hq/element-x-ios/commit/a85914f67)
- [ ] `bug:critical` Evict remaining under-map-lock awaits in `room_latest_event`'s thread arm, `forget_thread`, both `listen_and_subscribe` entry points, backfill-candidates loop ([`84c47e013`](https://github.com/matrix-org/matrix-rust-sdk/commit/84c47e013))
- [ ] `bug:critical` `try_read` + unconditional enqueue in the "re-trigger missing computations" loop (70s+ convoy under the `position` lock) ([`19e852a81`](https://github.com/matrix-org/matrix-rust-sdk/commit/19e852a81))
- [ ] `bug:major` Make all four `handleRoomRoute` routes (room, thread, event, share) cancellable from the modal ([`49f379d51`](https://github.com/element-hq/element-x-ios/commit/49f379d51))
- [ ] `diag` Log every await boundary on the room-open path ([`5c868e39c`](https://github.com/element-hq/element-x-ios/commit/5c868e39c))
- [ ] `infra` Dogfood install script kills every ElementX pid before installing (`--terminate-existing` only kills the newest install) (e2ee-rig `11c7545`)
### Reply previews of undecrypted events
- [ ] `bug:major` Say "Waiting for decryption key" and update in place when the key arrives [`bde3cbd69`](https://github.com/element-hq/element-x-ios/commit/bde3cbd69), redecryptor hook ([`17af054e3`](https://github.com/matrix-org/matrix-rust-sdk/commit/17af054e3))
- [ ] `bug:major` Save `/event`-fetched events into the event cache ([`4b23e1d77`](https://github.com/matrix-org/matrix-rust-sdk/commit/4b23e1d77), reverted [`50e72d627`](https://github.com/matrix-org/matrix-rust-sdk/commit/50e72d627) during the dup-echo bisection, reapplied in [`bdea86735`](https://github.com/matrix-org/matrix-rust-sdk/commit/bdea86735)) — fixes [#3113](https://github.com/element-hq/element-x-ios/issues/3113) and the "unsupported event in summary" half of [#4819](https://github.com/element-hq/element-x-ios/issues/4819) ([rageshake#6859](https://github.com/element-hq/element-x-ios-rageshakes/issues/6859)); related [#6002](https://github.com/element-hq/element-x-ios/issues/6002)
- [ ] `bug:minor` Reply previews sometimes show raw mxids: the embedded sender profile was a one-shot snapshot that the `/members` and member-change profile sweeps never descended into; both sweeps now refresh embedded reply profiles ([`f6e0cba3a`](https://github.com/matrix-org/matrix-rust-sdk/commit/f6e0cba3a)) — upstream bug
### Markdown lists
- [x] `bug:minor` Fix the first list item rendering more indented than the rest [`550a6467d`](https://github.com/element-hq/element-x-ios/commit/550a6467d) — fixes [#5179](https://github.com/element-hq/element-x-ios/issues/5179)
Handled in https://github.com/element-hq/element-x-ios/pull/6055
- [x] `bug:minor` Two parser regressions it introduced (del/ins/s classed as block tags ate spaces around strikethrough; adjacent blockquotes coalesced into one box) are fixed inside [`1a3a97317`](https://github.com/element-hq/element-x-ios/commit/1a3a97317) below
Also handled in https://github.com/element-hq/element-x-ios/pull/6055
### Formatted room previews and message-body structure (EXI)
- [ ] `qol` Render room-list previews from the HTML body instead of stripping to plain text: quote markers, bold/italic/code at the row size, strikethrough/underline remapped; applies to every flattened surface (room list, NSE, thread list, pinned banner) [`1a3a97317`](https://github.com/element-hq/element-x-ios/commit/1a3a97317)
- [ ] `bug:minor` Strip reply fallbacks from previews (`` and `> <@user>` lines), and fix duplicate `formattedComponents` identities that mangled quote-reply bubbles and overlapped the timestamp [`5ccc92263`](https://github.com/element-hq/element-x-ios/commit/5ccc92263)
- [ ] `bug:minor` Nested blockquotes rendered flat [`fcf00a121`](https://github.com/element-hq/element-x-ios/commit/fcf00a121), superseded by the tree-shaped component model below
- [ ] `bug:minor` Structure message bodies as a TREE of block components: code blocks inside quotes keep their box, quotes inside list items stay under their bullet, nested quotes draw inside a spanning outer bar [`76a39ee07`](https://github.com/element-hq/element-x-ios/commit/76a39ee07)
- [ ] `bug:minor` Sub/superscripts were full-size in flattened previews (round 25) [`1d4d12138`](https://github.com/element-hq/element-x-ios/commit/1d4d12138)
- [ ] `infra` RERECORD the FormattedBodyText snapshots before upstreaming (grouped quotes now render as separate boxes); unit-test target unblocked by [`fedf3ae3d`](https://github.com/element-hq/element-x-ios/commit/fedf3ae3d)
### Change role from the member sheet (EXI, new feature)
- [ ] `feature` Role row on the manage-member sheet: always shown when non-default, becomes a Compound picker when you can send power levels and outrank the member (admin/owner warnings reused from Roles & permissions) [`e8458da2b`](https://github.com/element-hq/element-x-ios/commit/e8458da2b); own user gets the same sheet with a self-demotion dialog [`8edfdf132`](https://github.com/element-hq/element-x-ios/commit/8edfdf132); chevrons on the own-user row [`3ba795880`](https://github.com/element-hq/element-x-ios/commit/3ba795880) — implements [element-meta#3028](https://github.com/element-hq/element-meta/issues/3028). 10 sheet tests; snapshot re-record owed
### Sent messages duplicating or getting lost
- [ ] `diag` Instrument local-echo reconciliation to catch duplicated sent-message echoes ([`9a24a6a76`](https://github.com/matrix-org/matrix-rust-sdk/commit/9a24a6a76)) — diagnoses [#4242](https://github.com/element-hq/element-x-ios/issues/4242), [rageshake#6945](https://github.com/element-hq/element-x-ios-rageshakes/issues/6945), [#6592](https://github.com/element-hq/element-x-ios-rageshakes/issues/6592), [#5789](https://github.com/element-hq/element-x-ios-rageshakes/issues/5789)
- [ ] `bug:major` Poison rooms whose update failed mid-way and reload the linked chunk before mutating ([`4eed9b8a2`](https://github.com/matrix-org/matrix-rust-sdk/commit/4eed9b8a2)) — fixes [rageshake#6945](https://github.com/element-hq/element-x-ios-rageshakes/issues/6945), likely [#4242](https://github.com/element-hq/element-x-ios/issues/4242)
- [ ] `diag` Widen the duplicated-echoes tripwire to the remotes region; log what `remove_events` removes ([`4a3906914`](https://github.com/matrix-org/matrix-rust-sdk/commit/4a3906914))
- [ ] `revert` FFI default log levels for timeline/send queue/event cache raised to debug [`038d0121c`](https://github.com/matrix-org/matrix-rust-sdk/commit/038d0121c), reverted as too noisy [`98f06f80f`](https://github.com/matrix-org/matrix-rust-sdk/commit/98f06f80f) (net zero)
- [ ] `bug:major` Merge the tachyon (DMLS) review's collapse/redelivery fixes §§1-5 ([`f5d631a4a`](https://github.com/matrix-org/matrix-rust-sdk/commit/f5d631a4a), [`59d0d4f3d`](https://github.com/matrix-org/matrix-rust-sdk/commit/59d0d4f3d))
- [ ] §6 (timeline-only state events under MSC4186) deliberately **not** merged — needs an upstream design discussion first
- [ ] `bug:critical` `replace_event_at` `.expect()`ed a position that §5 established can be stale by use time, and the in-place duplicate replacement now hits that path on every catch-up: log and skip instead of panicking ([`be6478b4a`](https://github.com/matrix-org/matrix-rust-sdk/commit/be6478b4a))
### Echoes bouncing back and forth on a send sequence
- [ ] `bug:major` Replace tail duplicates that form a prefix of the sync batch in place (and swap the eager copy's fabricated `origin_server_ts`) ([`b004a4b9b`](https://github.com/matrix-org/matrix-rust-sdk/commit/b004a4b9b)) — upstreamable; softens [#4242](https://github.com/element-hq/element-x-ios/issues/4242)-family churn
- [ ] `bug:major` Stale sync batch permanently reordered sent messages (rageshake 7467, "3,1,2,4"): known tail events in a non-gappy batch act as anchors that never move, unknown events slot in before their following anchor ([`4d97fa38a`](https://github.com/matrix-org/matrix-rust-sdk/commit/4d97fa38a)) + regression test
### Send animation glitches ([#4127](https://github.com/element-hq/element-x-ios/issues/4127), jig: [bubbleanim](https://github.com/element-hq/bubbleanim))
- [ ] `polish` Reconfigure the previous newest item inside the same animated snapshot apply [`1b450577b`](https://github.com/element-hq/element-x-ios/commit/1b450577b) (first attempt [`0125d9091`](https://github.com/element-hq/element-x-ios/commit/0125d9091) was worse)
- [ ] `polish` Pin hosted cell content to topLeading with `maxHeight: .infinity` to kill the residual dip [`66bea662f`](https://github.com/element-hq/element-x-ios/commit/66bea662f)
- [ ] `polish` Edge-pin rule follow-ups: a sender-header regroup under a closing gap spinner popped the bubble UP (it rides the cell's moving top edge), so content is bottom-pinned while the header toggles [`99d488ebc`](https://github.com/element-hq/element-x-ios/commit/99d488ebc); that over-matched (`.single → .first` on send is a bottom change) and regressed the normal send, now keyed on `shouldShowSenderDetails` flipping only [`c4174d1bd`](https://github.com/element-hq/element-x-ios/commit/c4174d1bd). Rule: top-pin by default, bottom-pin only while the header toggles. Upstream all three together
- [x] Validate both on the phone — done (user-validated 2026-08-13 and again 2026-08-20)
- [ ] `polish` The send transition (composer-collapse pop on multiline sends): composer reports its measured collapse delta, the timeline freezes its frame oversized and pins on the previous newest cell, the echo is laid out behind the composer's opaque background and revealed as it shrinks, ONE ease-out settle to a precomputed bottom; single-line sends keep the stock insert with a fade; screenful-tall sends snap. 18 commits, each phone-validated or refuted: [`0bbbfe5e4`](https://github.com/element-hq/element-x-ios/commit/0bbbfe5e4), [`e1a9fff97`](https://github.com/element-hq/element-x-ios/commit/e1a9fff97), [`6e4f71c3b`](https://github.com/element-hq/element-x-ios/commit/6e4f71c3b), [`0e268e0bd`](https://github.com/element-hq/element-x-ios/commit/0e268e0bd), [`4cf06d5aa`](https://github.com/element-hq/element-x-ios/commit/4cf06d5aa), [`36c5c6bd5`](https://github.com/element-hq/element-x-ios/commit/36c5c6bd5), [`cd9462cbf`](https://github.com/element-hq/element-x-ios/commit/cd9462cbf), [`43f1da9af`](https://github.com/element-hq/element-x-ios/commit/43f1da9af), [`9cb742b52`](https://github.com/element-hq/element-x-ios/commit/9cb742b52), [`d84983b4e`](https://github.com/element-hq/element-x-ios/commit/d84983b4e), [`816054ffb`](https://github.com/element-hq/element-x-ios/commit/816054ffb), [`7f96896ff`](https://github.com/element-hq/element-x-ios/commit/7f96896ff), [`4bb249f14`](https://github.com/element-hq/element-x-ios/commit/4bb249f14), [`b06904f5c`](https://github.com/element-hq/element-x-ios/commit/b06904f5c), [`b4ed1dd7d`](https://github.com/element-hq/element-x-ios/commit/b4ed1dd7d), [`03d924df9`](https://github.com/element-hq/element-x-ios/commit/03d924df9), [`0df3d522f`](https://github.com/element-hq/element-x-ios/commit/0df3d522f), [`8acdd35a9`](https://github.com/element-hq/element-x-ios/commit/8acdd35a9) (journey + before/after videos in the logbook). Composer-side pieces (measured delta, growth tween, caret-scroll suppression) stand alone. Strip the `SendTransition:` logs before upstreaming
- [ ] `polish` Sending from the emoji keyboard bounced the stack (round 30): upstream's keyboard-type reset on clear (#299) grows the view by 81pt mid-transition, so a single-line transition that sees the view grow promotes itself to the collapse path [`4186510fc`](https://github.com/element-hq/element-x-ios/commit/4186510fc)
- [ ] `infra` Send animation demo recording [`d3c7c0a7f`](https://github.com/element-hq/element-x-ios/commit/d3c7c0a7f) (docs)
### Blocks of sends vanishing then reappearing
- [x] `diag` Log every timeline diff batch on both sides: SDK [`6f60ba2e3`](https://github.com/matrix-org/matrix-rust-sdk/commit/6f60ba2e3), EXI [`365c091af`](https://github.com/element-hq/element-x-ios/commit/365c091af) (also implements the previously ignored `.truncate` diff)
Truncation handled in https://github.com/element-hq/element-x-ios/pull/6029. The others we don't want.
- [ ] `bug:critical` Ignore sync batches that are entirely known events and don't contain the newest in-memory event ([`6532fc2be`](https://github.com/matrix-org/matrix-rust-sdk/commit/6532fc2be)) — upstream together with the diagnostics commit
- [ ] `bug:critical` **BUGGY AS SHIPPED**: the guard conflated known-in-the-live-tail with known-but-stranded-behind-a-gap and ate the late echo of a stranded just-sent event, making a sent message invisible permanently (survived restarts). Narrowed so the known copies must all live in memory ([`cbf7545bc`](https://github.com/matrix-org/matrix-rust-sdk/commit/cbf7545bc)); the stranding itself fixed at the source by relocating the eager tail past a stale gappy batch ([`7fad14efb`](https://github.com/matrix-org/matrix-rust-sdk/commit/7fad14efb)); CHUNKDUMP/SYNCDUMP diagnostics that read the store state off the phone ([`62f1f2522`](https://github.com/matrix-org/matrix-rust-sdk/commit/62f1f2522), strip). Upstream [`6532fc2be`](https://github.com/matrix-org/matrix-rust-sdk/commit/6532fc2be) ONLY together with [`cbf7545bc`](https://github.com/matrix-org/matrix-rust-sdk/commit/cbf7545bc) + [`7fad14efb`](https://github.com/matrix-org/matrix-rust-sdk/commit/7fad14efb)
- [ ] Validate: burst-send on a poor connection, watch for the vanish, rageshake immediately
### Crash stabbing send as it switches to VM (Sentry `4671176f84f8`)
- [x] `bug:critical` Composer view model ignores sends while recording; `fatalError("invalid composer mode.")` downgraded to an error log [`e8b28d5ef`](https://github.com/element-hq/element-x-ios/commit/e8b28d5ef)
Fixed in https://github.com/element-hq/element-x-ios/pull/6018
- [x] `bug:minor` `lastCrashEventID` is a `CurrentValuePublisher` so the crash-report prompt isn't lost to a race with Sentry's `onCrashedLastRun` [`e8b28d5ef`](https://github.com/element-hq/element-x-ios/commit/e8b28d5ef)
Fixed in https://github.com/element-hq/element-x-ios/pull/6017
### First tap after a "Loading…" modal swallowed
~~- [ ] Remove the scrim and stop the overlay window intercepting the moment a retract begins (earlier belt: [EXI `1f669f332`](https://github.com/element-hq/element-x-ios/commit/1f669f332)); window-level touch logging landed alongside ([EXI `7ff0ea3fb`](https://github.com/element-hq/element-x-ios/commit/7ff0ea3fb), strip before upstreaming)~~
Dead end, doesn't do anything
- [ ] `bug:minor` ⚠️ **Possibly closed on the wrong commit: the change described above lives in [`77f4a6bc2`](https://github.com/element-hq/element-x-ios/commit/77f4a6bc2), which was never linked here; the two linked commits are only the earlier belt ([`1f669f332`](https://github.com/element-hq/element-x-ios/commit/1f669f332)) and the touch logging ([`7ff0ea3fb`](https://github.com/element-hq/element-x-ios/commit/7ff0ea3fb)).** The logbook records a systematic repro (first back-press after opening a thread from the room list does nothing, waiting ~500ms avoids it) and a root cause (the retracted indicator keeps its scrim and the overlay window's interactivity for the rest of `minimumDisplayDuration`), so please re-test with [`77f4a6bc2`](https://github.com/element-hq/element-x-ios/commit/77f4a6bc2) before leaving this as a dead end
### Room list wedged on skeletons (SDK + EXI)
- [ ] `bug:major` Home list emptied and stuck on skeletons after a session expiry during cold launch: `entries_with_dynamic_adapters` yielded the adapter chain into `switch()` so its death was unobservable; rebuild the chain immediately under the current filter and log the two previously-silent death sites ([`60a514641`](https://github.com/matrix-org/matrix-rust-sdk/commit/60a514641))
- [ ] `diag` Log the size of every (re)built dynamic entries chain ([`f2d87693b`](https://github.com/matrix-org/matrix-rust-sdk/commit/f2d87693b)); log the diff kinds that empty a populated list ([`e806f1d55`](https://github.com/element-hq/element-x-ios/commit/e806f1d55)) (keep, they caught the next one)
- [ ] `bug:major` Skeletons-forever deadlock: focusing search applies `.excludeAll`, a loading-state update then flipped the mode to `.skeletons`, which unmounts the search field so nothing ever re-applies `.all`; an empty list under an active search/filter is a real answer and stays in `.rooms`; `setFilter` logs its filter [`73a0c75fc`](https://github.com/element-hq/element-x-ios/commit/73a0c75fc)
- [ ] `bug:major` Offline/server-unreachable banners flashed on every sliding-sync session expiry: the sync service restarts silently on `UnknownPos` (10s anti-spin guard) ([`32ad3ed0c`](https://github.com/matrix-org/matrix-rust-sdk/commit/32ad3ed0c)), stale child termination reports drained on the silent restart or they killed the fresh sync ([`3d974500c`](https://github.com/matrix-org/matrix-rust-sdk/commit/3d974500c), bug in the previous commit); EXI debounces the banners 2s with immediate retract [`ecaf44a38`](https://github.com/element-hq/element-x-ios/commit/ecaf44a38)
### Gappy timelines: cached content always visible (SDK + EXI) — [#3872](https://github.com/element-hq/element-x-ios/issues/3872), [how-hard-can-it-be#115](https://github.com/element-hq/how-hard-can-it-be-2025/issues/115)
> Fresh port of Hywan's `feat-ui-timeline-with-gaps` idea onto preview-prefill: back-pagination (opt-in) walks storage only, straight past gaps; each gap is an inline spinner item resolved with one `/messages` when visible. Offline you see everything cached with spinners for the holes. Scoped to the live and Media & Files timelines.
- [ ] `feature` SDK core: `UpdateTimelineGaps` snapshot (gap + following-event anchor), storage-only pagination mode, `RoomEventCache::resolve_gap` with in-flight dedupe, gap items reconciled at the end of every timeline transaction, TimelineStart suppressed while a gap leads ([`512d3a184`](https://github.com/matrix-org/matrix-rust-sdk/commit/512d3a184), [`9455f3676`](https://github.com/matrix-org/matrix-rust-sdk/commit/9455f3676), [`b4bbbf085`](https://github.com/matrix-org/matrix-rust-sdk/commit/b4bbbf085), [`9208f40c8`](https://github.com/matrix-org/matrix-rust-sdk/commit/9208f40c8)); adjacent same-anchor gaps collapse to their newest member ([`d88bbf2a0`](https://github.com/matrix-org/matrix-rust-sdk/commit/d88bbf2a0))
- [ ] `feature` EXI: gap items render as spinners and fire `resolveGap` on appear; Media & Files keeps gap cells in both modes [`459dd1b09`](https://github.com/element-hq/element-x-ios/commit/459dd1b09), [`af51350b0`](https://github.com/element-hq/element-x-ios/commit/af51350b0); retry every 2s while visible (resolutions killed by backgrounding/network never retried) [`0c0c33ed2`](https://github.com/element-hq/element-x-ios/commit/0c0c33ed2)
- [ ] `polish` Resolve-animation policy (iterated over four recordings): shrink the spinner away on empty closes [`1d478a346`](https://github.com/element-hq/element-x-ios/commit/1d478a346); pin the visible timeline across off-screen resolves [`53c10646d`](https://github.com/element-hq/element-x-ios/commit/53c10646d); don't animate resolves that rewrite other visible rows (group-item identity churn cross-faded half the screen) [`25291a7be`](https://github.com/element-hq/element-x-ios/commit/25291a7be); animate only resolves that close empty, momentum-preserving `bounds.origin` pin [`912c06177`](https://github.com/element-hq/element-x-ios/commit/912c06177); round 28: animate whenever the spinner is on screen and the identifiers newer than the gap are unchanged (BubbleAnim harness experiment 3) [`9d3bee9d1`](https://github.com/element-hq/element-x-ios/commit/9d3bee9d1)
- [ ] `bug:major` Timeline frozen behind a cancelled drag gesture (`willBeginDragging` without `didEndDragging` parked every update in `hasPendingItems`): gate applies on UIKit's live `isTracking`/`isDragging` and flush from `scrollViewDidScroll` [`6586deb77`](https://github.com/element-hq/element-x-ios/commit/6586deb77) — pre-existing bug, made visible by gaps
- [ ] `bug:major` "History won't load behind a leading gap": no timeline start was ever inserted once the last leading gap resolved, so overscroll went dead until bg/fg; gaps now drive the timeline-start decision ([`ce16d5247`](https://github.com/matrix-org/matrix-rust-sdk/commit/ce16d5247)); ignore-filter removals emitted to timelines at once ([`bd9e6f428`](https://github.com/matrix-org/matrix-rust-sdk/commit/bd9e6f428)); events and their gap snapshot placed in ONE transaction (one-frame spinner jump per resolution) ([`ff480667c`](https://github.com/matrix-org/matrix-rust-sdk/commit/ff480667c)); leading-gap status test [`f07200ee9`](https://github.com/matrix-org/matrix-rust-sdk/commit/f07200ee9), fmt [`77d4ae611`](https://github.com/matrix-org/matrix-rust-sdk/commit/77d4ae611)
- [ ] `polish` [`ce16d5247`](https://github.com/matrix-org/matrix-rust-sdk/commit/ce16d5247)'s eager gap refresh committed a removal-only transaction before the events landed, so every resolution slid the rows below the spinner (round 17); dropped for resolutions ([`e83dfeaf3`](https://github.com/matrix-org/matrix-rust-sdk/commit/e83dfeaf3)). Upstream together
- [ ] `polish` Back-pagination landing a same-sender predecessor animated the top bubble's sender header away (`.animation(value: groupStyle)` meant for live sends); "loses sender details" regroups apply with animations disabled [`b8f5db3bf`](https://github.com/element-hq/element-x-ios/commit/b8f5db3bf)
- [ ] `bug:major` Pagination state updates lost while a timeline update was being built: `TimelineController` iterated `combineLatest` via `.values` (demand 1), so a `paginating → idle` pair dropped the `idle` for good and the spinner stuck; unlimited-demand sink coalescing to the latest update [`1c6c7f4bf`](https://github.com/element-hq/element-x-ios/commit/1c6c7f4bf) — same drop class could hide other late state updates
- [ ] `bug:major` Gaps whose followers are all filtered out of a timeline were never rendered (media-less rooms showed the empty state); unanchored gaps render at the newest end, storage walks continue up to 32 chunks until a matching event ([`e6b518ab7`](https://github.com/matrix-org/matrix-rust-sdk/commit/e6b518ab7)); pinned by test [`cd586ceb1`](https://github.com/matrix-org/matrix-rust-sdk/commit/cd586ceb1)
- [ ] `bug:major` False "beginning of the room" #1 (round 15): concurrent resolutions of stacked gaps dragged known events behind a newer walk's frontier and dropped the leading gap; a gap whose `/messages` duplicates live after it has been overtaken and is dropped ([`11abfd1d7`](https://github.com/matrix-org/matrix-rust-sdk/commit/11abfd1d7))
- [ ] `bug:major` False "beginning of the room" #2 (round 23): redundant-gap drops left a gap-free head that the storage walk took for the room's start; a gap-free head is the start only once no gap remains anywhere, otherwise exhausted storage resolves the oldest remaining gap over the network ([`f5574914c`](https://github.com/matrix-org/matrix-rust-sdk/commit/f5574914c))
- [ ] `polish` Gap resolutions jumped the timeline (round 21): resolving a gap on top of loaded history removed and re-inserted every known duplicate; known events in batch order stay as anchors, new runs insert before their following anchor, spanned gaps dropped ([`e553d7414`](https://github.com/matrix-org/matrix-rust-sdk/commit/e553d7414), [`40b80d934`](https://github.com/matrix-org/matrix-rust-sdk/commit/40b80d934))
- [ ] `bug:major` Media & Files pagination walks into the gaps (round 20): once storage is exhausted, message-type `paginate_backwards` resolves the next gap back instead of hard-stopping at the oldest cached media ([`07fc90598`](https://github.com/matrix-org/matrix-rust-sdk/commit/07fc90598))
- [ ] `diag` `duplicate read receipts` errors dumped the whole item list (68MB/hour of log): dump removed ([`3af5613ce`](https://github.com/matrix-org/matrix-rust-sdk/commit/3af5613ce)); whether the duplicates are a preview-prefill artefact is still open
- [ ] Open follow-ups from the issue thread: offline "content may be missing" hint instead of an eternal spinner; Slack-style placeholder design (mxandreas); watch aggregations across freshly resolved gaps; residual corner-radius animation on the item above a resolved gap
### The event cache is an index, not a cache (SDK)
> Principle: only an explicit clear (or a future size cap, or known-bad data) may empty it.
- [ ] `bug:major` Sync room updates delivered to the event cache over a dedicated lossless unbounded queue instead of the capacity-32 broadcast; the lag path that wiped EVERY room's persisted chunks on one missed broadcast is gone ([`a706d3bfd`](https://github.com/matrix-org/matrix-rust-sdk/commit/a706d3bfd))
- [ ] `bug:major` Linked-chunk updates (search index, thread subscriber, redecryptor) through a lossless per-subscriber fanout; each consumer silently skipped updates on lag (permanent search-index holes, missed redecryptions) ([`d77c69156`](https://github.com/matrix-org/matrix-rust-sdk/commit/d77c69156))
- [ ] `qol` Ignoring a user filters their events out of the existing cache (rooms + instantiated threads, memory + store) instead of wiping everything ([`9587599ea`](https://github.com/matrix-org/matrix-rust-sdk/commit/9587599ea)); latest event recomputed rather than cleared ([`d294849b8`](https://github.com/matrix-org/matrix-rust-sdk/commit/d294849b8), test). Known gap: never-instantiated persisted thread chunks can't be enumerated yet
### Media & Files index, media viewer, Manage storage (SDK + EXI)
- [ ] `perf` `events(room_id, session_id)` index (migration 018): the redecryptor's per-session encryption-info refresh scanned every event of the room, ~1.8s each, 15 back-to-back after launch, hogging the store and producing the "`pos` persistence is still waiting" warnings; plus a hashed `msgtype` column + `events(room_id, msgtype)` index, lazily backfilled ([`daea5b0e2`](https://github.com/matrix-org/matrix-rust-sdk/commit/daea5b0e2))
- [ ] `perf` `MessageTypesEventCache`: index-only projection of the persisted linked chunk onto the wanted msgtypes, kept live from the lossless fanout, gaps anchored or unanchored ([`3b387ee02`](https://github.com/matrix-org/matrix-rust-sdk/commit/3b387ee02)); `TimelineFocus::MessageTypes` ([`7544e8345`](https://github.com/matrix-org/matrix-rust-sdk/commit/7544e8345)); clippy [`9b2f8140e`](https://github.com/matrix-org/matrix-rust-sdk/commit/9b2f8140e); EXI builds both Media & Files grids on it [`38de1429e`](https://github.com/element-hq/element-x-ios/commit/38de1429e); seeded index-only with pages decoded on demand so a cold grid is one index query + one page ([`0e26df61e`](https://github.com/matrix-org/matrix-rust-sdk/commit/0e26df61e))
- [ ] `perf` Media viewer opens a message-types timeline seeded AROUND the tapped event, pageable both ways, window follows live appends once at the newest end ([`843292aec`](https://github.com/matrix-org/matrix-rust-sdk/commit/843292aec)); EXI [`27abd3d77`](https://github.com/element-hq/element-x-ios/commit/27abd3d77), [`1edf24d3c`](https://github.com/element-hq/element-x-ios/commit/1edf24d3c)
- [ ] `qol` Media viewer: preload neighbours so the next media slides in instead of a black page (seven builds + a device log; QuickLook parks fast-swiped pages on an unavailable placeholder that only `refreshCurrentPreviewItem` clears) [`e746f4b29`](https://github.com/element-hq/element-x-ios/commit/e746f4b29), [`fc85bea0c`](https://github.com/element-hq/element-x-ios/commit/fc85bea0c), [`eddee8ba0`](https://github.com/element-hq/element-x-ios/commit/eddee8ba0), [`c7a2be821`](https://github.com/element-hq/element-x-ios/commit/c7a2be821), [`13364ae7c`](https://github.com/element-hq/element-x-ios/commit/13364ae7c); stop at the ends instead of bouncing onto the placeholder + toast, same behaviour from the room screen as from the grid, neighbours preloaded from the room screen, "Preload media in viewer" advanced setting [`e82c58c13`](https://github.com/element-hq/element-x-ios/commit/e82c58c13). Strip `PreviewDebug` logs; the placeholder-detection workaround should be raised with the EX team
- [ ] `feature` Manage storage screen in Advanced settings: per-cache bar chart (message keys / room state / messages / media / logs) with clear buttons, per-room breakdown with multi-select scoping, confirmation alerts. SDK: `StorageUsage` + store trait methods + selective clearing + FFI ([`88a7907a0`](https://github.com/matrix-org/matrix-rust-sdk/commit/88a7907a0), [`ac895b612`](https://github.com/matrix-org/matrix-rust-sdk/commit/ac895b612), [`f0070d5c0`](https://github.com/matrix-org/matrix-rust-sdk/commit/f0070d5c0)); on-disk totals, media URIs indexed on write (`event_media`, migration 019) ([`6b95f506d`](https://github.com/matrix-org/matrix-rust-sdk/commit/6b95f506d)); trigger-maintained per-room byte counters (migrations 020 event cache + 016 state store) ([`2d44d71f3`](https://github.com/matrix-org/matrix-rust-sdk/commit/2d44d71f3), [`9f25138a8`](https://github.com/matrix-org/matrix-rust-sdk/commit/9f25138a8)); media attributed in one query, rooms in batches ([`6c2d1487e`](https://github.com/matrix-org/matrix-rust-sdk/commit/6c2d1487e)); rooms reported in one go ([`f8b8b7cfa`](https://github.com/matrix-org/matrix-rust-sdk/commit/f8b8b7cfa)). EXI: [`1bfc65c0e`](https://github.com/element-hq/element-x-ios/commit/1bfc65c0e), [`37e87950f`](https://github.com/element-hq/element-x-ios/commit/37e87950f), [`470dc4d9b`](https://github.com/element-hq/element-x-ios/commit/470dc4d9b), [`107753bb5`](https://github.com/element-hq/element-x-ios/commit/107753bb5), [`eec67d48c`](https://github.com/element-hq/element-x-ios/commit/eec67d48c), [`4de28ca89`](https://github.com/element-hq/element-x-ios/commit/4de28ca89), [`af1ae89fb`](https://github.com/element-hq/element-x-ios/commit/af1ae89fb), [`96c5b31ef`](https://github.com/element-hq/element-x-ios/commit/96c5b31ef); bar colours pinned to Compound light-mode core tokens ([`217289b85`](https://github.com/element-hq/element-x-ios/commit/217289b85), [`30c6c8ae2`](https://github.com/element-hq/element-x-ios/commit/30c6c8ae2), [`579a0078b`](https://github.com/element-hq/element-x-ios/commit/579a0078b))
### Crypto / sync rounds (SDK)
- [ ] `bug:major` Room preview stuck on "Waiting for message" for a decrypted message (round 34): a UTD that is a room's latest-event stayed undecrypted while backgrounded even though its megolm key was already in the store, because the key had been imported by the NSE (a separate process) so it never reached the app's room-key stream, and the redecryptor's regeneration/`Lagging` path retries only in-memory UTDs while this one had been shrunk out to the persisted event cache; opening the room decrypted it at once. New store-backed sweep `Redecryptor::retry_persisted_events` reads every encrypted room's persisted UTDs straight from the store (no cache instantiation; only a room with a decryptable UTD gets instantiated when the event is written back and the latest-event recomputed), run once at startup and on every regeneration/`Lagging`, batched 20 rooms with a 20ms pause; test `test_persisted_utd_sweep_heals_out_of_band_key` ([`08063697b`](https://github.com/matrix-org/matrix-rust-sdk/commit/08063697b)) — upstream candidate, the in-memory-only retry gap exists on main
- [ ] `bug:major` Room key ~21s late after foregrounding (round 24): `send_sync_request` awaited the outgoing E2EE requests (five ~1MB `/keys/query`) BEFORE handling the sync response that carried the key; handle the response first ([`41edd7f48`](https://github.com/matrix-org/matrix-rust-sdk/commit/41edd7f48)) — upstream bug
- [ ] `perf` ~5MB of `/keys/query` per process start: the encryption sliding-sync connection never shared its `pos`, so every launch/background refresh started with `pos=None` and marked all tracked users dirty (and the NSE destroyed the app's server-side connection at every push); `share_pos()` on the encryption connection + room-less responses persist their `pos` at once ([`7b0388401`](https://github.com/matrix-org/matrix-rust-sdk/commit/7b0388401)) — upstream candidate
- [ ] `bug:major` Redecryptor deaf after the NSE bumps the crypto store generation (rageshake 7515): the FFI `SessionVerificationController` pins a `UserIdentity` (hence the old OlmMachine's store) for the app lifetime so the room-key streams never ended; `regenerate_olm` now broadcasts and the redecryptor rebuilds its streams and retries in-memory UTDs ([`b1d3ac2d3`](https://github.com/matrix-org/matrix-rust-sdk/commit/b1d3ac2d3)) — upstream bug (the pinning exists on main). Not fixed: the pinned `UserIdentity` also means `isVerified()` answers from a pre-regeneration snapshot
### Invite screen (EXI)
- [ ] `bug:minor` 1:1 invite offline showed the inviter twice (room named after its only known member, `isDirect && memberCount == 1` failed on count 0) and the user ID wasn't copyable; `invitePresentsAsDM` = nobody but the inviter known and (direct or room named after the inviter), `.textSelection(.enabled)` on title/subtitle/user ID [`6159f5a72`](https://github.com/element-hq/element-x-ios/commit/6159f5a72) — upstream has the identical check
- [ ] `infra` `TimelineControllerFactoryMock` default media-filtered controller [`a33d41884`](https://github.com/element-hq/element-x-ios/commit/a33d41884)
### Notifications clearing (EXI)
- [ ] `bug:minor` Upstream's "remove delivered notifications for fully-read rooms" (899c33a5a) compared the notification's *delivery* date with the latest message's `origin_server_ts`, so the newest notification always survived and a read-elsewhere stack collapsed to one; the NSE now stamps `event_timestamp` into the notification userInfo and the app compares that (fallback to delivery date), gated by a new advanced setting "Clear notifications read elsewhere" (default on) [`a007d33a5`](https://github.com/element-hq/element-x-ios/commit/a007d33a5)
- [ ] `bug:major` The removal was triggered by `ClientProxyAction.receivedSyncUpdate`, which fires only when the room-list STATE becomes `.running` (once per resume, before that sync's receipts land), so nothing cleared; now driven by the static room summaries publisher, debounced 1s, with "Removing N notifications" / "Keeping notifications for " logs [`6c244c9c1`](https://github.com/element-hq/element-x-ios/commit/6c244c9c1)
### Thread timelines: storage-first pagination with inline gaps (SDK + EXI, round 31)
> Thread contents were very slow to load and felt like they bypassed the event cache. They didn't, but the thread cache was only ever read one chunk deep; every limited sync stamped the room's `prev_batch` onto each thread as a gap and shrank the thread to its last chunk, so each open had to serially `/relations` back through those gaps (plus a 3s initial-token wait) before cached replies appeared. An upstream from-the-end all-duplicates page also dropped its token and claimed the start of the thread, so live-created threads longer than a batch never loaded their root.
- [ ] `perf` SDK: the room's storage-first machinery ported to threads: `ThreadPagination::run_backwards_once_from_storage` walks stored chunks past gaps (no network), `ThreadEventCache::timeline_gaps()` exposes them as the same `TimelineGap` snapshot as the room (observers pull it alongside every `TimelineVectorDiffs` update), `ThreadEventCache::resolve_gap` resolves one on demand (in-flight dedupe shared with the room via `GapResolutionsInFlight`); network only once storage is exhausted, remaining gaps oldest-first then "from the end" until the root leads; `reached_start` never claimed without the root leading and no gap left; the from-the-end all-duplicates page parks its token in front of the oldest known event instead of dropping it; the 3s token wait skipped for threads. UI: thread timelines honour `storage_only_pagination` (storage walk, gap items, `resolve_gap`, diffs + gaps applied in one transaction). Tests `test_storage_only_pagination_serves_stored_events_past_gaps` (no `/relations` mock mounted) and `test_pagination_from_the_end_progresses_past_known_events` ([`4c664bbc8`](https://github.com/matrix-org/matrix-rust-sdk/commit/4c664bbc8)) — the false "beginning of thread" half is an upstream bug
- [ ] `perf` EXI: `threadTimeline` opens with `storageOnlyPagination: true`; the gap item view and resolve action were already timeline-kind agnostic [`d0a37e742`](https://github.com/element-hq/element-x-ios/commit/d0a37e742)
- [ ] `bug:major` Spinner before the thread root on reopen (round 35): a thread whose root arrived in a limited room sync is stored as `[gap][root, replies]` (the room's prev-batch stamped as a gap before the root); the storage walk surfaced that gap as a spinner and resolved it with a doomed `/relations`, though nothing precedes a thread root. When the walk loads a gap chunk while the root already leads, drop it (persisting the removal, healing the stored chunk) and conclude the thread start ([`35648826a`](https://github.com/matrix-org/matrix-rust-sdk/commit/35648826a)) — upstream together with `4c664bbc8`
- [ ] `polish` Two spinners at the top of every thread load (round 33): a store exhausted behind a leading gap had that gap resolved over the network by the pagination itself, so the pagination indicator spun above the gap item already spinning for the same hole; the room's rule now applies to threads (a leading gap is the start as far as pagination goes, its gap item resolves it on demand) ([`aa8e073ad`](https://github.com/matrix-org/matrix-rust-sdk/commit/aa8e073ad)) — upstream together with `4c664bbc8`
### Network handover stalls and a blank-until-drag timeline (SDK + EXI, round 32, rageshakes 7542 / 7543)
- [ ] `bug:major` Walking out of Wi-Fi coverage, every request in flight (the send's `/keys/claim`, typing, presence, `/members`, both sliding-sync long-polls) sat on a black-holed connection for its full 30s/60s timeout, retried on the same dead pool and hung again; everything only recovered a minute later when iOS tore the Wi-Fi socket down and all the retries went out over 5G at once. The OS had reported nine "reachable" path updates in that minute that went unused. New `Client::notify_network_change()` (FFI `notifyNetworkChange`): `HttpClient` keeps the `HttpSettings` it was built from plus a `watch` generation; a network change rebuilds the `reqwest` client (fresh pool) and bumps the generation, and every in-flight attempt races against it and re-sends immediately on the fresh client without consuming a retry attempt or backoff delay; OAuth refresh and the QR rendezvous channel read through the same swappable client; test `test_network_change_resends_in_flight_request` ([`a05714866`](https://github.com/matrix-org/matrix-rust-sdk/commit/a05714866)) — upstream candidate; note `Client::http_client()` now returns an owned `reqwest::Client` snapshot rather than a reference. Not done: h2 keep-alive pings as a passive detector for outages that come with no path update
- [ ] `bug:major` EXI: `NetworkMonitor.pathUpdatePublisher` fires on every `NWPathMonitor` update, reachable or not, with the interface list logged, and `ClientProxy` forwards each one to `notifyNetworkChange()` [`8e2331a2e`](https://github.com/element-hq/element-x-ios/commit/8e2331a2e)
- [ ] `bug:major` A DM opened to a blank timeline until the first tap-drag (rageshake 7543): the SDK delivered the items 80ms after the open, a touch landed on the table 60ms later, and [`6586deb77`](https://github.com/element-hq/element-x-ios/commit/6586deb77)'s gate on `isTracking || isDragging` parked them, since a finger merely resting on the table is tracking and a tap without a drag produces no scroll callback to flush. Gate on `isDragging` alone, UIKit's self-clearing "actively dragging" state [`8e2331a2e`](https://github.com/element-hq/element-x-ios/commit/8e2331a2e) — follow-up of `6586deb77` above, upstream together
### Media viewer: swipe-into-black and the timeline ends (EXI, round 36, builds 76-100)
> Logbook: `docs/dogfood-branches.md`, "Round 36" and follow-ups #1-#6. Two dead ends (a KVO-driven edge rubber-band, and thumbnail/blurhash placeholder files for QuickLook) were tried and fully reverted; their commits aren't listed, but the surviving commits below that also carried them are flagged.
- [ ] `bug:major` The media-filtered timeline only paginated once the user had landed on a `.paginating` placeholder (already black): now paginates ahead of the swipe, when the current media is within 5 of the loaded window's edge [`ddec7ad4f`](https://github.com/element-hq/element-x-ios/commit/ddec7ad4f) (also introduced the edge rubber-band, replaced by the native bounce below)
- [ ] `qol` Native bounce at the first/last media instead of a hard-pinned swipe: the data source's 100-slot phantom padding collapses to zero once a side is `endReached` (gated on a real pagination state having been seen, as `.initial` is `endReached/endReached`), so the last real item is QuickLook's own content edge and its scroll view rubber-bands natively; the controller carries the current item across the one-off index shift and reloads only on a count change; test `endReachedCollapsesPhantomPadding` [`0dbd64d2a`](https://github.com/element-hq/element-x-ios/commit/0dbd64d2a) (also carried a bounded thumbnail preload, removed again in [`045467ef5`](https://github.com/element-hq/element-x-ios/commit/045467ef5))
- [ ] `bug:major` Swipe into black: QuickLook builds the two pages either side of the current one from `previewItemURL` at build time and never re-reads a page, so a neighbour whose preload landed after the build stayed cached blank. A neighbour file arriving while the user rests now triggers a debounced `reloadData` that rebuilds the built pages (the heal), tracked by a model of which pages were built without a file [`5a5d22735`](https://github.com/element-hq/element-x-ios/commit/5a5d22735); a page the user lands on blank is cleared with `reloadData` rather than `refreshCurrentPreviewItem`, which reliably failed to clear the placeholder on device [`30b5a828b`](https://github.com/element-hq/element-x-ios/commit/30b5a828b)
- [ ] `polish` The heal reload's flash is hidden behind a `snapshotView` of the page scroll view inserted directly above it (below the bars, so their glass keeps animating) and dropped the instant the rebuilt page has content: on iOS 26.5 the pages are in-process (`_UIQueuingScrollView` → plain `UIView` page containers → `QLPreviewScrollView` → `UIImageView` / `AVPlayerLayer`), `reloadData` empties the current container synchronously and repopulates it ~20ms (image) to ~130ms (video) later, and the "content unavailable" placeholder never appears during a rebuild; detection = a new visible `UIImageView.image` or `AVPlayerLayer.isReadyForDisplay` under the container beneath the view centre, a drag or a 1s cap also drops it, the arrival refresh of an already-blank page stays uncovered (measured: 2 covered reloads in 76 swipes, cover down after 17ms and 23ms; the 26.5 simulator hosts the whole QuickLook UI as one remote ExtensionKit scene, so it can't be used for this). Same commit: the once-per-rest heal guard keyed on `contentOffset.x` (which QuickLook reuses after a reload, suppressing heals across pages) now keys on the item; neighbour preload fixed at a symmetric nearest-first reach (QuickLook only builds media pages at ±2 and the pipe is shared; supersedes the reach iterations in [`5184e82f9`](https://github.com/element-hq/element-x-ios/commit/5184e82f9) / [`09100f7a2`](https://github.com/element-hq/element-x-ios/commit/09100f7a2)); `PreviewDebug` logging and the dormant thumbnail fallback removed [`b9b01c433`](https://github.com/element-hq/element-x-ios/commit/b9b01c433); reach settled on 8 after a ±3 cut brought back the 4th-swipe black (the 4th file was only queued one swipe before QuickLook built its page) [`5b3deb996`](https://github.com/element-hq/element-x-ios/commit/5b3deb996); one info log line each for heal reloads, blank landings and the cover, so a session shows pre-healed vs landed-on blanks [`a713b865f`](https://github.com/element-hq/element-x-ios/commit/a713b865f)
### Media & Files: 4-5s to open on the message-type index (SDK, round 37, build 101)
> Tapping Media & Files in Room Info took 3-4s reproducibly, prewarm-from-Room-Info included: the two timeline builds spent ~5.8s in the SQLite event cache store, all of it scanning. Logbook: `docs/dogfood-branches.md`, "Media & Files: 4-5s to open".
- [ ] `perf` `find_event_refs_by_message_types` ran the legacy `msgtype` backfill on every call, and finding the legacy rows is a whole-table `SCAN events` (no index leads with `event_type`, content blobs read along): ~1.5s per call on a 5k-room store, two calls per media view and one per files view. Now recorded once under a kv key, like the room event-size counters [`891f0122f`](https://github.com/matrix-org/matrix-rust-sdk/commit/891f0122f).
- [ ] `perf` `load_events_by_refs` fetched a page of ~50 events with `room_id = ? AND event_id IN (...)`; once `ANALYZE` stats exist (`PRAGMA optimize`) the planner takes the `(room_id, ...)` index over the `IN` list for a page-sized list and walks the whole room (~2s in a large room; reproduced on a synthetic store). The refs are primary keys and are now looked up as such (same commit).
### Media viewer: directional preload, queued before the current item lands (EXI, round 38, build 102)
- [ ] `perf` Viewer preload reach was ±8 (17 files per open). ±3 isn't enough on its own (download time × swipe rate bounds it, not QuickLook's ±2: phone photos take 1-3s on cellular), so keep 8 only in the swipe direction (±3 on open, 8 ahead / 2 behind once swiping) and queue the neighbours as soon as the item becomes current rather than after its own load (the old `defer` ordering left QuickLook building ±1/±2 blank at open) [`7bb7688e7`](https://github.com/element-hq/element-x-ios/commit/7bb7688e7).
### Composer caret drawn on the wrong line / hopping while editing (EXI, round 39, builds 103-107, user-validated)
- [ ] `bug` Caret rendered one to several lines above the insertion point for a few frames per keystroke in a scrolled composer (long message / editing): every SwiftUI layout pass re-measured the live `UITextView` (0 / infinity / real-width probes), each `sizeThatFits` resizing the text container and transiently clamping `contentOffset`; now the width probes are answered from the cached height and the same-width height is read off the live layout manager [`1d50e73bc`](https://github.com/element-hq/element-x-ios/commit/1d50e73bc); the text binding animates only on line-count changes, plus temporary `CARETPROBE` logging to strip [`76c2ea1a5`](https://github.com/element-hq/element-x-ios/commit/76c2ea1a5); then no forced text layout while typing at all (contentSize-based measure, animate only under the height cap) after a one-line-down bounce on deleting linefeeds in a scrolled composer [`4f44e42c7`](https://github.com/element-hq/element-x-ios/commit/4f44e42c7).
- [ ] `bug` While editing a message `updateUIView` re-applied `attributedText` on every keystroke (the view's storage drifts from the pushed binding by attributes UIKit adds while typing, and the `!=` check took that for a binding change), resetting selection and bouncing content size/scroll offset each time: apply the binding only when it differs from what the wrapper last pushed or applied [`e6a4137e0`](https://github.com/element-hq/element-x-ios/commit/e6a4137e0); probe stripped [`6f0cf8c50`](https://github.com/element-hq/element-x-ios/commit/6f0cf8c50). Upstream bug. Upstream candidate (measurement path is upstream code; visible whenever the composer is scrolled).
### Timeline stops responding to drags until scroll-to-bottom (EXI, round 40, build 108, diagnostics only)
> Rageshake 7549: drags on a room timeline did nothing (programmatic scrolls worked; a scroll-to-bottom tap cleared it) after several ~150ms active/inactive flicks while backgrounded. Touches reached the cells, UIKit's pan never recognised; which recogniser was left mid-gesture the log can't say. Logbook: `docs/dogfood-branches.md`, "Round 40".
- [ ] `diag` `TouchDebug` now also logs, on touch-begin and only when something is off, every recogniser in the window still in `.began`/`.changed`, the hit scroll view's state and ancestors mid-animation [`32f90239f`](https://github.com/element-hq/element-x-ios/commit/32f90239f). Dogfood-only (the whole `TouchDebug` hook is stripped before upstreaming).
### Media viewer: thumbnail shown while the full-size media downloads (EXI, round 41, build 109)
> Tapping an image whose thumbnail the timeline had drawn but whose file wasn't downloaded sat on a spinner over black until the file landed. Logbook: `docs/dogfood-branches.md`, "Round 41".
- [ ] `qol` When an item becomes current without its file, the in-memory cached thumbnail is drawn into a temp JPEG at the media's own pixel size (capped at 2048 on the longest side, so QuickLook lays it out as it will the media and the swap is a sharpen in place) and handed to QuickLook as the preview URL; the spinner stays up. On arrival the page is refreshed (`refreshCurrentPreviewItem`, verified against the rendered content, covered `reloadData` fallback); placeholder-built pages are tracked like blank ones for the resting heal and arrival check; the temp directory dies with the view model [`90edabed1`](https://github.com/element-hq/element-x-ios/commit/90edabed1). Needs device validation before upstreaming.
- [ ] `bug:major` Opening the viewer from a room timeline could land on an item ~N older than the one tapped (or on the paginating placeholder): the round-36 padding-collapse code shifted QuickLook's index by the change in the data source's first index on any count change, but a prepend absorbed by the padding (the media timeline's first reset) moves the first index without moving a page. The current item's index is now re-derived absolutely from the data source on count changes; the initial load starts at view-model init and presentation waits ≤150ms so cached media opens straight to the file and only downloading media gets the thumbnail placeholder (tapped item and preload neighbours) [`2eca4747a`](https://github.com/element-hq/element-x-ios/commit/2eca4747a). Supersedes the placeholder plumbing of `90edabed1`.
### Media viewer: one "loading more" page with the native bounce beyond it (EXI, round 42, builds 115-123, user-validated)
> Swiping off the oldest loaded media paged into a run of identical "Loading more..." placeholders (the index-stability padding). Logbook: `docs/dogfood-branches.md`, "Round 42" (each commit = a device-observed QuickLook wedge and the rule learned).
- [ ] `qol` While on a "loading more" page the data source reports one page beyond the loaded items on that side, so QuickLook's edge bounce stops the swipe; when its items arrive the viewer steps onto the newest of them; back on media the padding returns [`656dc2b44`](https://github.com/element-hq/element-x-ios/commit/656dc2b44) (log split `8e8c9a119`).
- [ ] `bug:major` Clamp transitions from inside QuickLook's index callback toggled forever (QuickLook drops index writes made there): watchdog kill [`e7cd6c958`](https://github.com/element-hq/element-x-ios/commit/e7cd6c958); an index beyond QuickLook's last-read count is dropped, so set after the reload, and stale "loading more" pages are refreshed not clamped [`2e8b59f5a`](https://github.com/element-hq/element-x-ios/commit/2e8b59f5a); index jump in the same turn as the reload wedges the page queue, so 0.1s later [`49ff69188`](https://github.com/element-hq/element-x-ios/commit/49ff69188); no clamp from the reload's own callback while a move is pending [`fbcecf7d3`](https://github.com/element-hq/element-x-ios/commit/fbcecf7d3); refresh/reload only at rest (mid-deceleration wedges QLPreviewController) [`34989c881`](https://github.com/element-hq/element-x-ios/commit/34989c881) [`6b887f8d3`](https://github.com/element-hq/element-x-ios/commit/6b887f8d3).
- [ ] `bug:major` The data source ignored any timeline update in which its items weren't a contiguous run of the new list (upstream); the media timeline's dedup/backfill churn breaks that constantly, freezing the viewer at a few items until the timeline reached the room's start and the padding collapsed (stuck on an old image, no "loading more"). Such updates are taken, re-anchored on the current item, pages rebuilt behind the cover at rest [`5274de125`](https://github.com/element-hq/element-x-ios/commit/5274de125). Upstream bug.
- [ ] `qol` Thumbnail placeholder from the SDK media store when the memory-only image cache has let the timeline's thumbnail go [`d634097e6`](https://github.com/element-hq/element-x-ios/commit/d634097e6).
### Media viewer: thumbnail placeholder UX, faster tap-to-viewer (EXI, round 43, builds 127-129)
> Logbook: `docs/dogfood-branches.md`, "Round 43".
- [ ] `qol` Placeholder only after a 300ms grace (cached media never shows one); no spinner over it, the header reads "Loading..." in place of the sender and the filename shows; the thumbnail-to-media swap runs under the page cover with the bar buttons' re-animation left visible as the cue; videos keep their poster. The placeholder is prepared alongside the grace for the initial item (was ~140ms of synchronous JPEG writing on the presentation path) and the timeline's tap spinner only shows if building the media timeline takes over 300ms (~100ms from the event cache) [`93cf06e2d`](https://github.com/element-hq/element-x-ios/commit/93cf06e2d) (grace wait as a poll [`460c6cf22`](https://github.com/element-hq/element-x-ios/commit/460c6cf22)).
### Media viewer: UTD-aware pagination (no more skipping ~20 media on "Loading more"), and the redecryptor lag behind it (EXI + SDK, round 44, builds 131-138)
Unsure this round worked at all; round 61 below actually got the media viewer to handle UTDs ok.
> Swiping back into history sometimes jumped ~20 media too far, the skipped ones inserting behind the user later: /messages pages fetched ahead of their room keys are all UTDs, invisible to the msgtype-filtered media timeline, so the viewer paged on to the next decrypted media. Logbook: `docs/dogfood-branches.md`, "Round 44".
- [ ] `diag` Timeline shape log (`M`/`G`/`U`/`P`/`S`/`D`/`o` per item, oldest first) [`71ef1e0bd`](https://github.com/element-hq/element-x-ios/commit/71ef1e0bd); gap-aware step rule (don't step while a gap remains between the edge and the target) [`f9681ba7e`](https://github.com/element-hq/element-x-ios/commit/f9681ba7e).
- [ ] `bug:major` SDK: the room-key broadcast stream (capacity 10) overflowed on the ~117 single-session backup imports of one swipe-back, so the redecryptor reported `Lagging`, swept all rooms' persisted UTDs (~11s) inline in its loop and lagged again: the in-between media decrypted ~14s late. Capacity 1024, the sweep off-loop and coalesced [`b80c5ff7a`](https://github.com/matrix-org/matrix-rust-sdk/commit/b80c5ff7a). Upstream candidate.
- [ ] `bug:major` SDK: the msgtype-filtered view shows undecryptable events ("maybe media once the key arrives"; decryption replaces them in place or removes them, test added) [`0e0579380`](https://github.com/matrix-org/matrix-rust-sdk/commit/0e0579380); the FFI `OnlyMessage` timeline filter lets `m.room.encrypted` through [`59f270858`](https://github.com/matrix-org/matrix-rust-sdk/commit/59f270858). Upstream candidates.
- [ ] `bug:major` EXI: a UTD of unknown cause between the current item and the next older media counts like a gap for the step rule, and back-pagination is held while such UTDs sit older than the oldest media (nearest page's keys first); final causes ignored, `.unknown` given up on after 5s [`275671ca1`](https://github.com/element-hq/element-x-ios/commit/275671ca1); the hold is released as soon as the UTDs resolve (was: only on the next swipe, "Loading more…" spun for 25s) [`0992805e7`](https://github.com/element-hq/element-x-ios/commit/0992805e7).
- [ ] `perf` SDK: `update_encryption_info` ran one crypto-store lookup per decrypted event of a session for every key received (the info only depends on session + sender); with ~300 backup downloads the redecryptor fell 5-7s behind its keys. One lookup per session [`2ed0284a5`](https://github.com/matrix-org/matrix-rust-sdk/commit/2ed0284a5). Upstream candidate.
- [ ] `perf` EXI: media timelines page 50 events at a time [`0d32214de`](https://github.com/element-hq/element-x-ios/commit/0d32214de).
- Next: batch the redecryptor's per-event replacements (one store txn + one diff + one EXI rebuild per event today, ~2-3s for a 50-UTD page); a low-priority "index to room start" backfill request for media browsing/search.
### Redecryptor bulk replacements, upstream merge, narrower UTD sweep (SDK + EXI, round 45, builds 139-140f)
> Round 44's remaining per-page cost (one store lookup + one store transaction per resolved UTD), then `origin/main` + `origin/develop` merged into the branches (upstream's solutions preferred, ours layered on top only where the branch still needs them, see the dogfood doc round 45 for the per-item list), then the startup UTD sweep narrowed.
- [ ] `perf` SDK: bulk `EventCacheStore::find_events` / `save_events` (SQLite: one `IN` query, one write transaction), room-state `find_events`, used by the redecryptor [`0fd1acf75`](https://github.com/matrix-org/matrix-rust-sdk/commit/0fd1acf75); upstream candidate (upstream's reworked `on_resolved_utds` already calls `save_events` once, so only the store-side bulk methods + `find_events` remain ours).
- [ ] `infra` Merges: SDK [`821ec9e95`](https://github.com/matrix-org/matrix-rust-sdk/commit/821ec9e95), EXI [`9cd67edf3`](https://github.com/element-hq/element-x-ios/commit/9cd67edf3). Items now carried by upstream (ours removed): #6895, #6012, #6016, #6017, #6018, #6019, #6029, #6038, #6045, #6055, #6056, #5996, the thread-aggregator deadlock fix. Kept on top and flagged: HomeScreenViewModel empty-filter / zero-count guards, StateLock holder tickets, OAuth `cached_server_metadata`, thread `root_leads`.
- [ ] `perf` SDK: the startup/Lagging UTD sweep retries only each room's latest-event UTD (from the persisted latest-event value) instead of scanning every encrypted room's persisted events and re-attempting every long-term UTD per launch [`5140c5839`](https://github.com/matrix-org/matrix-rust-sdk/commit/5140c5839); older UTDs are retried on room open (timeline builder) or by the key stream.
- [ ] `infra` EXI post-merge fix-ups (SDK mocks regenerated against the local bindings, `ClientBuilder.username` -> `serverNameFromUserId`) [`c6cfceb47`](https://github.com/element-hq/element-x-ios/commit/c6cfceb47).
- Note: `5859d1c52`/`5e55f53e9` above look upstreamed by #6045 (develop reports the visible range live through `didScroll`; `!range.isEmpty` guard present): tick after a look.
### Version-bump VACUUM on the launch critical path (EXI, round 46, build 141)
- [ ] `perf` EXI: `performUserSessionMigrations` no longer awaits `clientProxy.optimizeStores()` (sqlite `VACUUM` of the state, event cache and media stores; the SDK doc on `Client::optimize_stores` says "DO NOT use in production") before the session flow starts [`7fd658b77`](https://github.com/element-hq/element-x-ios/commit/7fd658b77). On a 295MB state store + 835MB event cache + 485MB media store the merged build sat on the app placeholder skeletons for 8 minutes (11s state store, event cache VACUUM never finished, abandoned when the process was killed); the VACUUM also holds the event cache's single write connection, so the first sync batch wedged `handle_joined_room_update` behind it while holding the state lock (the stall diagnostics named it). Upstream suggestion: gate on `PRAGMA freelist_count` (don't rewrite 835MB to reclaim little) and run detached after the home screen is up; the gate is the important half.
### Media viewer: header vs QuickLook's title, galleries browsed inline (EXI, round 47, builds 142-143)
- [ ] `bug` EXI: the sender/timestamp header (navigation item `titleView`) was only installed from `viewWillLayoutSubviews`; QuickLook swaps its navigation item on the refreshes that re-install its list button, after which the item's filename (`previewItemTitle`) showed until the next layout. Reapplied from `updateBarButtons` (same KVO + timer trigger) [`383b7c761`](https://github.com/element-hq/element-x-ios/commit/383b7c761). Upstream-relevant: same code on develop.
- [ ] `ux` EXI: galleries are browsed inline with the room's other media instead of a viewer scoped to the gallery (swipes no longer trapped in the subset); the tapped attachment is the initial item of the timeline-spanning data source, header shows "Alice (2 of 3)"; gallery-only inits + `displayGalleryPreview` removed [`01f22400b`](https://github.com/element-hq/element-x-ios/commit/01f22400b). Product decision for upstream; data source tests adapted (not yet run locally). Follow-up: the not-yet-loaded gallery fallback is filtered like the timeline's flattening so the merge is a contiguous range (was leaving "Loading more" padding around the gallery) [`b33630f3e`](https://github.com/element-hq/element-x-ios/commit/b33630f3e) (also carries a dogfood `WindowDebug` diagnostic, strip).
- [ ] `bug` EXI: `GalleryItemID` equality was synthesised over the whole `TimelineItemIdentifier` (per-timeline `uniqueID` included), so a gallery attachment never matched the media timeline's copy of itself in the viewer ("Ignoring update: unable to find existing preview items range"); compare event/transaction ID + `mediaIndex` [`193220393`](https://github.com/element-hq/element-x-ios/commit/193220393). Upstream bug (same struct on develop).
### Media viewer: a gallery tap browses the tapped kind only (EXI, round 47 follow-up, build 146)
- [ ] `ux` Tapping a gallery attachment opens the timeline-spanning viewer filtered by the tapped attachment's kind, matching the filter the not-yet-loaded gallery fallback already assumes, so the merge stays a contiguous range [`5bc16305b`](https://github.com/element-hq/element-x-ios/commit/5bc16305b).
### Media transfer progress bars, duplicate downloads, stalled-transfer handover (SDK + EXI, rounds 48-50, builds 148-153)
> Download feedback in the viewer, upload feedback on timeline media, and the two bugs they exposed: one video fetched twice feeding one bar, and the round-32 network-change re-send firing on NWPathMonitor noise. Logbook: `docs/dogfood-branches.md`, rounds 48-50.
- [ ] `feature` SDK: HTTP responses can stream with `recv_progress` observed (native reads `chunk()` when subscribed, total from Content-Length; wasm keeps `bytes()`); `get_media_file` takes a `ProgressWatcher` [`4ea0801f1`](https://github.com/matrix-org/matrix-rust-sdk/commit/4ea0801f1). Upstream candidate.
- [ ] `feature` EXI: download progress bar in the media viewer, upload progress restored on timeline image/video/gallery bubbles (send-queue upload progress was dropped), long-press Retry on an uploading item kicks stalled sends via `notify_network_change` [`13ab60176`](https://github.com/element-hq/element-x-ios/commit/13ab60176) (preview/a11y registrations [`e9ad259ee`](https://github.com/element-hq/element-x-ios/commit/e9ad259ee)).
- [ ] `bug:minor` EXI: one `.itemLoaded` per loaded item (each fired a page rebuild; viewer unit tests green again) [`83ea54d9d`](https://github.com/element-hq/element-x-ios/commit/83ea54d9d); one download per file however many callers ask mid-flight (the viewer re-requested a page mid-download and fetched the video twice, bouncing the bar) [`2fffa09de`](https://github.com/element-hq/element-x-ios/commit/2fffa09de).
- [ ] `bug:major` SDK: the round-32 network-change watcher re-sent every in-flight request on any path change; NWPathMonitor emitted 128 spurious changes in 10 minutes, restarting healthy downloads. A watched transfer is only re-sent once it has actually stalled (2s without progress), and progress resets on re-send [`c17016f4d`](https://github.com/matrix-org/matrix-rust-sdk/commit/c17016f4d); EXI forwards real path changes only [`2fffa09de`](https://github.com/element-hq/element-x-ios/commit/2fffa09de). Upstream candidate (refines the round-32 item).
- [ ] `bug:minor` EXI: a sent local echo's viewer page survives its transaction-ID -> event-ID flip (data source matches on the echo) [`46ec23c75`](https://github.com/element-hq/element-x-ios/commit/46ec23c75); uploads-in-progress serve the viewer from the send-queue cache.
- [ ] `polish` `dead-end` Bar styling iterations, net result = a 3pt bar with a themed track along the screen's bottom edge: 2pt accent strip [`cdaa4e208`](https://github.com/element-hq/element-x-ios/commit/cdaa4e208) -> 3pt themed track [`46ec23c75`](https://github.com/element-hq/element-x-ios/commit/46ec23c75) -> content's bottom edge [`0681dd368`](https://github.com/element-hq/element-x-ios/commit/0681dd368) -> screen's bottom edge (content-edge tracking fell apart once the placeholder was zoomed) [`ba4987692`](https://github.com/element-hq/element-x-ios/commit/ba4987692).
### Manage storage: per-room clears that actually clear, and the room filter (SDK + EXI, rounds 49-61, builds 152-174)
> Follow-ups to the round-30s Manage storage screen: scope correctness, clear ordering, VACUUM so the numbers move, and the storage-by-room search. Logbook: rounds 49, 53, 54, 61.
- [ ] `qol` EXI: session-wide caches (logs) read zero and grey out when rooms are selected, instead of setting the chart scale [`b25c0e818`](https://github.com/element-hq/element-x-ios/commit/b25c0e818); caches labelled 0.0 MB draw an empty bar rather than scaling up [`3fe3c011d`](https://github.com/element-hq/element-x-ios/commit/3fe3c011d).
- [ ] `bug:major` EXI: clearing a room's messages found its media already unattributable (media is located through the stored messages), so per-room media clears deleted nothing (`num_uris=0`); media is cleared BEFORE messages/state, and clearing messages clears the room's media too [`7c9345398`](https://github.com/element-hq/element-x-ios/commit/7c9345398) (SDK per-room stats diagnostic [`02ec3cdc0`](https://github.com/matrix-org/matrix-rust-sdk/commit/02ec3cdc0), strip).
- [ ] `bug:minor` SDK+EXI: deleted rows left the SQLite files the same size (freed pages kept for reuse), so the shown sizes never moved: per-store VACUUM (`optimize_event_cache_store` / `optimize_media_store`) [`4ad7b3756`](https://github.com/matrix-org/matrix-rust-sdk/commit/4ad7b3756), run after a clear under the same "Please wait" [`4049cfeae`](https://github.com/element-hq/element-x-ios/commit/4049cfeae).
- [ ] `bug:critical` EXI: typing in the storage-by-room filter hung the app until force-quit on accounts with thousands of rooms: the listed-rooms filter (locale-aware contains over every room, 6171 here) was a computed property read per row per render. Search field added [`bf8d4a5e4`](https://github.com/element-hq/element-x-ios/commit/bf8d4a5e4) (as an in-list row, a navigation-bar field also hung [`05b6a53c1`](https://github.com/element-hq/element-x-ios/commit/05b6a53c1)); the list is now stored and recomputed once per rooms/selection/query change [`80406b24f`](https://github.com/element-hq/element-x-ios/commit/80406b24f).
### Media viewer: page rebuilds, placeholder holds, autoplay (EXI, rounds 50-58, builds 153-166)
> The long tail of QuickLook page-staleness bugs after the round-36-44 work: pages built before their items or files existed, holds on "Loading more", and placeholder polish. Logbook: rounds 50-57.
- [ ] `bug:major` Items landing inside QuickLook's built pages (a cold event cache backfilling) rebuild them when resting, not only when landed on; reloads never run on an active touch (a reload landing on a touch-begin wedged QuickLook: every pan accepted, no page moved) [`9c24a8df3`](https://github.com/element-hq/element-x-ios/commit/9c24a8df3); a rebuild owed while swiping stays owed and runs at the next rest, wherever that is [`392f91a2a`](https://github.com/element-hq/element-x-ios/commit/392f91a2a); the heal guard resets after a rebuild so files landing right after opening still heal [`457315857`](https://github.com/element-hq/element-x-ios/commit/457315857).
- [ ] `bug:major` The wait on a "Loading more" placeholder is bounded from when the user landed on it (a room full of UTDs restarted the per-message wait with every backfill, 13s stuck while decrypted media sat unreachable behind the clamp); past the bound the viewer steps onto what has arrived [`be19c36c4`](https://github.com/element-hq/element-x-ios/commit/be19c36c4); the UTD-expiry timer re-arms so the shape re-evaluates when waits run out [`4049cfeae`](https://github.com/element-hq/element-x-ios/commit/4049cfeae).
- [ ] `qol` A video whose poster the user sat through the download on autoplays once swapped in [`fcea29238`](https://github.com/element-hq/element-x-ios/commit/fcea29238) (also carries an unavailable-page diagnostic, strip); thumbnail placeholders for the neighbours QuickLook builds alongside the current page [`7c9345398`](https://github.com/element-hq/element-x-ios/commit/7c9345398); videos of unknown size count as large (an unsized neighbour video starved the on-display download) [`87adad141`](https://github.com/element-hq/element-x-ios/commit/87adad141) + QuickLook selection/caret tinted like the composer.
- [ ] `bug:minor` The placeholder job reads the item's kind on the main actor before the detached JPEG write [`efad94e57`](https://github.com/element-hq/element-x-ios/commit/efad94e57); QuickLook is never handed a nil URL [`1784bad6c`](https://github.com/element-hq/element-x-ios/commit/1784bad6c).
- [ ] `bug:major` A thumbnail poster arriving after the timeout was dropped and the page stayed black until the media landed: the task-group race waited for its load child (the unstructured load was only cancelled after the group returned), so the timeout neither bounded the wait nor kept a late result; replaced by a poll ending on arrival, the media landing, or the timeout (30s on display) [`48a63eb63`](https://github.com/element-hq/element-x-ios/commit/48a63eb63).
- [ ] `polish` `dead-end` Play badge on video posters: baked into the poster at thumbnail density [`457315857`](https://github.com/element-hq/element-x-ios/commit/457315857)/[`be19c36c4`](https://github.com/element-hq/element-x-ios/commit/be19c36c4), then replaced by the timeline's vector `VideoPlayBadge` as a controller overlay shown while the current page is a poster [`99789e4dc`](https://github.com/element-hq/element-x-ios/commit/99789e4dc).
### Timeline: Select text, double-tap reactions (EXI, rounds 56-60, builds 163-173)
- [ ] `feature` "Select text" replaces "Copy text" in the message menu: the bubble's text becomes selectable in place (edit menu presented at once, selection and Select All stop at the content, not the invisible timestamp spacer) [`429a1fe2d`](https://github.com/element-hq/element-x-ios/commit/429a1fe2d), [`754da0428`](https://github.com/element-hq/element-x-ios/commit/754da0428), [`6f9de30a5`](https://github.com/element-hq/element-x-ios/commit/6f9de30a5); the long-press text drag that produced a stuck floating preview while selecting is blocked [`488088568`](https://github.com/element-hq/element-x-ios/commit/488088568).
- [ ] `feature` Double tap on a bubble opens the reaction picker (UIKit recognizer; the SwiftUI one ate the first single tap) [`98a7fa562`](https://github.com/element-hq/element-x-ios/commit/98a7fa562) + the emoji picker sheet presents without the slide-in wait [`6f9de30a5`](https://github.com/element-hq/element-x-ios/commit/6f9de30a5).
### Store size: zstd compression in the SQLite stores (SDK, round 60, builds 172-173)
> An 875MB event cache measured as 335MB of content: most rows exceeded the 4KB page's ~1KB overflow threshold, so nearly every row cost two-plus pages. Logbook: round 60.
- [ ] `perf` Values are zstd-compressed (level 3) before the store cipher in `encode_value`/`decode_value` (ciphertext is incompressible, so compression must come first); the zstd frame magic marks compressed values, so rows written before the change read back untouched and incompressible media stays raw — no migration, no launch work [`dfb5ed884`](https://github.com/matrix-org/matrix-rust-sdk/commit/dfb5ed884). Existing stores shrink as they churn and fully on the next Manage-storage clear+VACUUM. Upstream candidate.
- [ ] `dead-end` 16 KB page size (new DBs at creation, existing ones on the next VACUUM via a WAL->DELETE->VACUUM->WAL dance) shipped in the same commit and was dropped again [`8ba77b9e2`](https://github.com/matrix-org/matrix-rust-sdk/commit/8ba77b9e2): compressed rows fall back under the 4KB overflow threshold, so the remaining gain was marginal against a risky live-pool journal conversion.
### Media viewer: redecrypted events never reached the media timeline (SDK, round 61, build 174)
> Swiping media in a freshly cache-cleared room left "missing media" holes that a reopened viewer didn't have: the media-only timeline applied only Inserts while the live timeline applied dozens of Sets in the same second.
- [ ] `bug:major` The redecryptor writes decrypted events to the store silently (`save_events`) and drains the in-memory `ReplaceItem` updates, so nothing keyed on the linked-chunk update fanout — the msgtype-filtered view behind the media viewer, and the search index — ever learned a UTD had been replaced. The in-memory replacements are now forwarded to the fanout as the `ReplaceItem`s they are (no store write) and the store-only ones by event ID in a new `replaced_events` field, which the view applies as a Set (or a removal when the event decrypted into something it isn't about); regression test drives a real redecryption into a msgtype view [`1ae6577e4`](https://github.com/matrix-org/matrix-rust-sdk/commit/1ae6577e4). Side effect: redecrypted events now reach the search index. Upstream candidate (both halves).
### Cancellable media downloads, "Stop downloads when swiping away" (SDK + EXI, round 62, build 176)
> Swiping over a run of video thumbnails quietly fetched all of them: cancelling the Swift task never reached the Rust future, so an abandoned video kept downloading gigabytes.
- [ ] `feature` SDK: `download_media_file` returns at once with a `MediaFileDownloadHandle` (`join()` / `cancel()`; the abort drops the transfer, nothing of it is kept); `get_media_file` delegates to it [`0da52ae48`](https://github.com/matrix-org/matrix-rust-sdk/commit/0da52ae48). Upstream candidate (foreign async cancellation doesn't propagate through uniffi).
- [ ] `feature` EXI: `MediaLoader` shares one cancellable FFI download per source with a waiter count, cancelling over the FFI once every caller has cancelled; new advanced option (default off) "Stop downloads when swiping away": leaving an item cancels its in-flight download when it's large (or preloading is off) and closing the viewer cancels them all, so only downloads the user is explicitly waiting on use data; swiping back retries [`8f276e402`](https://github.com/element-hq/element-x-ios/commit/8f276e402).
### Blank Chats after foregrounding: stuck search-focus self-heal (EXI, round 63, build 178)
> A cold relaunch during a sliding-sync session-expiry recovery came up with a permanently blank Chats list ("Start a chat", then nothing): SwiftUI's `isSearching` environment glitched to true with no search session, which hides the room list and filters AllRooms to `.excludeAll` — the SDK then rebuilds the chain with `num_rooms=0` and nothing ever restores it. The flag is known-flaky (a variant after cancelling search was already papered over with a delay hack).
- [ ] `bug:critical` Self-heal: if the room list is hidden for an empty-query "search" but nothing is first responder a second later, the stale flag is cleared and the filter subscription restores `.all` (heals in ~1s instead of blank-until-force-quit; heal logs as an error for visibility) [`b5bbaf4b3`](https://github.com/element-hq/element-x-ios/commit/b5bbaf4b3).
### Reaction picker: genuinely instant double-tap sheet (EXI, rounds 63-64, builds 177-187)
> The double-tap picker still felt ~200ms late. Root causes peeled in order: the sheet animated despite `animated: false` (two bugs), then the grid popped in a beat after the sheet, then ~150ms of one-shot SwiftUI build remained (profiled: ~30ms UIKit sheet machinery, ~80ms view-graph build, ~25ms first draw — diffuse). Logbook: rounds 63-64.
- [ ] `bug:minor` `Transaction.disablesAnimations` never reaches UIKit's sheet presentation, and the stack-coordinator's sheet proxy to the split coordinator dropped the `animated` flag: unanimated sheet presentations now disable UIView animations globally, re-enabled in the sheet content's `onAppear` [`710c26621`](https://github.com/element-hq/element-x-ios/commit/710c26621).
- [ ] `perf` The picker is prewarmed once per session (~1s after the first room presents) with an off-screen build + render inside the real window (a detached prewarm defers the real build and the first presentation redoes the whole graph via `updateEnvironment`), and reads its categories synchronously when the emojibase datasource has loaded, so the grid is part of the sheet's first layout [`5d2d85024`](https://github.com/element-hq/element-x-ios/commit/5d2d85024) (+ in-window prewarm kept from the dead-end below).
- [ ] `diag` Double-tap-to-sheet timing probes [`796c8f5ab`](https://github.com/element-hq/element-x-ios/commit/796c8f5ab) and an in-app main-thread sampler (2ms suspend + frame-pointer walk, folded stacks to the log, offline `atos -offset` symbolication) [`b4ab40742`](https://github.com/element-hq/element-x-ios/commit/b4ab40742)/[`f1868df5c`](https://github.com/element-hq/element-x-ios/commit/f1868df5c). Strip before upstreaming.
- [ ] `dead-end` Presenting a cached, prewarmed `UIHostingController` directly as a UIKit sheet to skip the per-presentation view-graph rebuild [`91ab9fc0b`](https://github.com/element-hq/element-x-ios/commit/91ab9fc0b) + environment/background fixes [`90db5e808`](https://github.com/element-hq/element-x-ios/commit/90db5e808): worked, but the plain no-animation SwiftUI sheet was judged fast enough and the bypass wasn't worth its edges; reverted [`5e40b98a7`](https://github.com/element-hq/element-x-ios/commit/5e40b98a7), keeping the in-window prewarm and the selection fixes below.
### Message text: the timestamp spacer can't be selected or copied (EXI, round 64, builds 180-187)
> The invisible spacer that reserves room for the overlaid timestamp (newline + transparent attachment) could be swept into a selection by dragging a handle to the bubble's end (pasting a stray blank line), and a double tap past the text left a stuck selection of it — drag handles and all — because the system's word selection bypasses both selection setters and the delegate-level clear.
- [ ] `bug:minor` Three-layer fix in `MessageTextView`: both selection setters clamp to `contentRange` [`02cb59ea5`](https://github.com/element-hq/element-x-ios/commit/02cb59ea5); outside "Select text" the text view's own recognisers never receive a second tap [`73b54bc29`](https://github.com/element-hq/element-x-ios/commit/73b54bc29); and the view refuses first responder outside "Select text" — the part that actually kills the stuck selection [`90db5e808`](https://github.com/element-hq/element-x-ios/commit/90db5e808).
### Timeline scroll freeze (rageshake 7549): two triggers, a self-heal (EXI, rounds 65+67, builds 188-193)
> The recurring freeze: drags on the timeline are tracked but never scroll (pan recogniser stuck in `possible`, offset frozen), while programmatic scrolls — jump to bottom, pagination inserts — still work. The TouchDebug diagnostic (added for this in an earlier round) has now caught two live instances with different causes.
- [ ] `bug:major` Trigger 1 (self-inflicted, round 64): the second-tap block above refused the double tap's touches to UIKit's `_UIRelationshipGestureRecognizer` plumbing (`dragFailureRelationships`, `clickPresentationFailure`, …) — the arbiters of failure/exclusion links between gestures — leaving the table's pan waiting on a relationship that never resolved until a jump to bottom recycled the cells. The block was redundant (the first-responder refusal is the fix that matters) and is deleted [`a949e70c1`](https://github.com/element-hq/element-x-ios/commit/a949e70c1). Supersedes the `73b54bc29` layer in the spacer section above.
- [ ] `diag` Trigger 2 (the original 7549) reproduced with the block already gone: same signature, but *nothing* visible to the window sweep — no active recognisers, no animations — minutes after a video played in the media viewer (AVKit recognisers were mid-gesture as it dismissed). Working theory: a phantom touch whose end/cancel a dismissed view swallowed. TouchDebug now dumps the event's full touch set (view, phase, age) whenever a touch isn't alone, plus the pan's `numberOfTouches` [`cd3db0491`](https://github.com/element-hq/element-x-ios/commit/cd3db0491). Strip before upstreaming.
- [ ] `bug:major` Self-heal in the same commit: four consecutive touches on one scroll view that track but never drag with a frozen offset reset its pan recogniser (`isEnabled` off/on drops whatever it still thinks it tracks), logging "pan wedged" as the confirmation signal [`cd3db0491`](https://github.com/element-hq/element-x-ios/commit/cd3db0491).
### Manage Storage: clear-all restarts behind the splash, sizes stay honest (EXI, rounds 65-66, builds 188-190)
> Clearing the whole state store ran its slow media/keys clearing under a "Please wait" modal that then lingered over the restart's splash screen; and because deleting rows leaves SQLite files their old size (freed pages are kept for reuse), the screen kept reporting hundreds of MB of "messages" right after a full clear — the stale file size, not reality.
- [ ] `qol` The restart path skips the modal entirely: `clearCache(alsoClearing:)` hands media/keys clearing to the AppCoordinator to run behind the splash, before the SDK's `clearCaches()`; logs are deleted inline (instant). Scoped per-room clears keep the modal (the screen stays up for those) [`20b7287fd`](https://github.com/element-hq/element-x-ios/commit/20b7287fd).
- [ ] `bug:minor` The cleared stores are vacuumed after clearing — they're near-empty at that point so it's quick, unlike a full-store VACUUM — so the sizes shown after the restart are real [`20b7287fd`](https://github.com/element-hq/element-x-ios/commit/20b7287fd).
- [ ] `qol` The screen re-measures quietly every 3s while open (no loading states, no error alerts; sequential awaits give natural backpressure), so totals track the post-clear re-sync live instead of freezing at open time [`53b4d5c08`](https://github.com/element-hq/element-x-ios/commit/53b4d5c08).
### Select text in bubbles: handles, spacer, exits (EXI, rounds 66-67, builds 190-193)
> Follow-ups to the timestamp-spacer section above, from using "Select text" in anger: the selection handles' knobs rendered clipped until a drag re-hosted them, the trailing handle could still sweep up the spacer, drags on the spacer lifted drag previews, and the mode was awkward to leave.
- [ ] `bug:minor` Dragged handles set the selection through the text-interaction controller, bypassing both the clamping setters and the delegate: the clamp now lives at the geometry chokepoint — `closestPosition(to:)` answers with the content's end for any point over the spacer [`4aae3c3f8`](https://github.com/element-hq/element-x-ios/commit/4aae3c3f8) (delegate-level clamp as a first attempt in [`76c7f05e7`](https://github.com/element-hq/element-x-ios/commit/76c7f05e7)).
- [ ] `bug:minor` The cropped handle knobs: the text view wasn't the clipper — the content's `cornerRadius` clip and the bubble background's rounded clip were. Both expand outward while the item is selecting, and the bubble's rounding is now painted by the background shape rather than produced by the clip, so lifting the clip doesn't square the corners [`4aae3c3f8`](https://github.com/element-hq/element-x-ios/commit/4aae3c3f8) (+ `clipsToBounds` off on the text view itself [`76c7f05e7`](https://github.com/element-hq/element-x-ios/commit/76c7f05e7)).
- [ ] `polish` Touches on the spacer never hit the text view outside "Select text" (`point(inside:)` excludes its glyph rect): they fall through to the bubble — double tap reacts, long press menus, scroll works — which also kills drag-lifts started on the whitespace; the text-drag interaction is re-disabled after text updates rebuild the interaction stack [`76c7f05e7`](https://github.com/element-hq/element-x-ios/commit/76c7f05e7) (the composer-tint part of that commit was reverted on dogfood feedback [`93075b59c`](https://github.com/element-hq/element-x-ios/commit/93075b59c)).
- [ ] `qol` Leaving the mode: swipe-to-reply is disabled while a selection is active (the selection drag fought it), and a tap outside the selected text — timeline background included, not just other bubbles — ends Select text [`4aae3c3f8`](https://github.com/element-hq/element-x-ios/commit/4aae3c3f8).
- [ ] `feature` New advanced setting (default off): double tapping a message selects its text instead of opening the reaction picker; non-message items keep the picker either way [`34c067f85`](https://github.com/element-hq/element-x-ios/commit/34c067f85).
### Media viewer: captions vs video controls, progress bar vs screen corners (EXI, rounds 66-67, builds 190-193)
> A video's caption sits over the scrubber, and the tap that hid it went to QuickLook — hiding all the chrome and pausing playback with it; and the bottom-edge download bar's ends were truncated by the display's curved corners.
- [ ] `qol` The first tap while a caption is on show hides just the caption: a viewer-level tap recognizer that every single-tap recognizer in the hierarchy defers to (swept on the existing timer — pages rebuild their recognisers) claims the touch only then, and never over the nav bar, the caption's own links or visible player controls. Any page change reshows the caption, so swipe away and back recovers it [`cb4c57749`](https://github.com/element-hq/element-x-ios/commit/cb4c57749).
- [ ] `polish` The download progress bar is inset 24pt from each screen edge, clear of the display corner radius's ~17pt incursion into the bottom 3pt [`382f407a4`](https://github.com/element-hq/element-x-ios/commit/382f407a4).
### Media viewer: the deterministic swipe-to-black, solved (EXI, round 68, builds 194-200)
> The viewer's oldest ghost: swiping at a normal cadence through a cold-cache room landed on a black page, almost always on the 4th image. A per-page diagnostic (each `previewItemAt` logs whether the page is handed the file, the thumbnail, or the shared black loading image [`c79d84ae8`](https://github.com/element-hq/element-x-ios/commit/c79d84ae8) — to be stripped before upstreaming) proved two QLPreviewController facts: `reloadData` requests **every** loaded item synchronously, and QuickLook **never re-reads a page between reloads**. So the first item whose file landed after the open-time rebuild was cached as a black page forever, and the open-time reach boundaries made that deterministically the 4th image; the heal reload that should have rebuilt it scanned only ±2 (contradicting its own comment) and could not fire at a steady swipe cadence anyway (its one-shot resting check always landed mid-gesture — page-settle deceleration overlaps the next touch).
- [ ] `bug:major` Pages built with the black loading image heal as soon as they have anything real to show — the thumbnail, not just the file — and a page built from the thumbnail is swapped for the media as before [`381eba8bb`](https://github.com/element-hq/element-x-ios/commit/381eba8bb).
- [ ] `bug:major` The heal check scans the whole loaded range for a stale blank page rather than ±2 around the current item [`5cafac8bb`](https://github.com/element-hq/element-x-ios/commit/5cafac8bb), coalesces for 150ms and then waits (bounded, 2s) for a quiet gap in the gesture stream instead of dying when a swipe is in flight [`1e11d73b7`](https://github.com/element-hq/element-x-ios/commit/1e11d73b7).
- [ ] `bug:major` Thumbnail placeholders are written for **every** loaded item, not a fixed reach around the current one — a reach only moves where the first black page sits (3 made it the 4th swipe, 8 the 9th). Jobs are once-per-item, served from the in-memory image cache first (the timeline just drew these thumbnails), then disk, then a thumbnail-sized network fetch [`1e11d73b7`](https://github.com/element-hq/element-x-ios/commit/1e11d73b7), [`95a003a3f`](https://github.com/element-hq/element-x-ios/commit/95a003a3f).
- [ ] `bug:minor` Data-usage guard: the no-thumbnail highres-poster fallback is confined to the item on display. For a speculative neighbour it would fetch full-size content (encrypted media can't be server-thumbnailed) into the image cache — duplicating the later file download, and for a thumbnail-less video downloading the file just to fail decoding it [`520e4dcc7`](https://github.com/element-hq/element-x-ios/commit/520e4dcc7). Full-file preloading is unchanged: ≤10MB, 3 undirected / 8 ahead / 2 behind, nearest first.
- [ ] `bug:minor` The residual "black flash as the thumbnail became the photo": the reload cover dropped when the rebuilt page's image view had its `image` *set* (~17ms), but a large photo decodes out of process for another 50-150ms. The cover holds 150ms past detection for image pages; video pages already gate on `AVPlayerLayer.isReadyForDisplay` [`0e3710742`](https://github.com/element-hq/element-x-ios/commit/0e3710742).
- [ ] `bug:minor` MSC4409 senders skip the thumbnail when the original is already thumbnail-sized (<=800x600), which the thumbnails-only guard then starved: those pages built black although the full-res costs what the thumbnail would have. The placeholder pass fetches them (pixel size from the event, else a small file size), and thumbnail-less items are fetched as full content rather than via the thumbnail endpoint, which cannot serve encrypted media - such items could never get a poster even on display [`0690a1e24`](https://github.com/element-hq/element-x-ios/commit/0690a1e24).
- [ ] `bug:minor` The black flashes that survived the round: the cover itself. Under a reload burst, each new covered reload re-snapshotted the page mid-rebuild (a snapshot of a just-emptied page is black, so the "cover" installed was a black image) and the previous reload's stale watcher then tore down the cover the newer reload had just put up, within microseconds. Covers now hand over: a standing cover is reused, the old watcher cancelled, and only the newest watcher may drop it; plus a 600ms floor between heal reloads so arrival bursts coalesce into one covered reload [`ffd23d71d`](https://github.com/element-hq/element-x-ios/commit/ffd23d71d).
- [ ] `bug:minor` The ~100-slot phantom padding doubled as a silent per-session browse cap: a spent side had no "Loading more" page left, so its edge bounced exactly like the end of the timeline while further merges landed unreachably below index 0. The budget is re-centred before it runs out (<=10 slots left and the timeline genuinely continuing): both paddings restore to their initial 100 at the next rest under a covered reload with the current index re-derived [`dd583295d`](https://github.com/element-hq/element-x-ios/commit/dd583295d).
- [ ] `docs` The viewer's full architecture (QuickLook's build/re-read behaviour, the page layers, fetch reaches, the built-page model and heal paths, the cover's handover semantics, timeline-change merging and scaling bounds) is written up in `docs/media-viewer.md` [`26d9cdfde`](https://github.com/element-hq/element-x-ios/commit/26d9cdfde).
### Read marker: own read receipts and cross-client reading (round 69)
- [ ] `bug` rust-sdk: the timeline's read marker (the NEW line) was placed solely from `m.fully_read`, which Element X only writes when leaving a room - a client sitting in the room chatting never advances it, and own read receipts arriving via sync fed only the unread badge, never the marker. The NEW line could therefore predate messages the user had already sent from another client. The marker is now placed at the later of `m.fully_read` and the visible event holding the user's own latest read receipt (public or private), and incoming explicit receipts re-run the marker update so it advances live; regression test included [`41efd33fd`](https://github.com/matrix-org/matrix-rust-sdk/commit/41efd33fd).
### Event cache: gappy sync must not resurrect UTDs (round 69, rageshake 7561)
- [x] `bug` rust-sdk: a limited/gappy sync (or pagination) returns raw, still-encrypted copies of events; if an event had already been decrypted and stored - typically by the NSE decrypting a push in another process - deduplication replaced the decrypted copy with the encrypted one, in memory and in the store, for room and thread chunks alike. The room timeline usually recovered via the redecryptor's retries, but a thread cache cleared+rebuilt by the gappy sync kept the resurrected UTD indefinitely: opening a thread from a correctly-decrypted push showed the message as a UTD. `filter_duplicate_events` now grafts the stored decrypted payload onto any incoming UTD already known decrypted in the room's store, covering every chunk of the room (the decrypted copy may live in a different chunk than the ingesting one); regression test included [`fafbf75e6`](https://github.com/matrix-org/matrix-rust-sdk/commit/fafbf75e6).
### Timeline: diffable data source snapshot re-entrancy (round 69, rageshake 7562)
- [ ] `bug` EXI: UIKit raises `NSInternalInconsistencyException` ("Deadlock detected: attempted to apply a snapshot to diffable data source while it was already applying a snapshot") if `dataSource.apply` is re-entered. An animated apply fires `scrollViewDidScroll` synchronously mid-batch, whose self-heal flush (added for a previous wedge) could call `applySnapshot` again, as could a SwiftUI view update replacing the items dictionary; a gap resolution swapping rows under an active pan crash-looped the app twice. `applySnapshot` now parks re-entrant calls and flushes them once the in-flight apply returns [`985cfdf98`](https://github.com/element-hq/element-x-ios/commit/985cfdf98).
### Linked chunk: OrderTracker panic "The chunk is not found" (round 69, rageshake 7563)
- [ ] `bug:crash` rust-sdk: the single-process variant of matrix-rust-sdk#5416 (the multi-process shrink case was fixed by matrix-rust-sdk#6757). `OrderTracker::map_updates` applied store-only updates (e.g. removals of deduplicated events living in the store) onto a mapper that could have unflushed in-band updates pending - the tracker is only flushed on ordering queries, `updates_as_vector_diffs`, or a shrink, so a room whose timeline is never opened keeps its boot seed while syncs create new chunks; a store-only `RemoveItem` referencing such a chunk panicked with "The chunk is not found" (as_vector.rs), crash-looping the app. Hit reliably on this branch because the latest-events backfill paginates thousands of unopened rooms against NSE-written stores. `map_updates` now flushes pending in-band updates first; regression test reproduces the exact production panic without the fix [`c60d0cae3`](https://github.com/matrix-org/matrix-rust-sdk/commit/c60d0cae3). Should be confirmed on matrix-rust-sdk#5416 when upstreamed.
### Threads: no "beginning of thread" affordance (round 70)
- [ ] `feature` rust-sdk + EXI: a thread paginated all the way back to its root looked identical to one whose back-pagination silently stalled - rooms show "This is the beginning of X." (`TimelineStart`), threads showed nothing. Thread timelines now emit the `TimelineStart` virtual item once the thread root leads the known events (which provably is the start: nothing can precede a root), guarded by the existing leading-gap logic; re-evaluated by the thread updates task after every applied diff batch, by the gaps handler for gap-only updates, and at init for a cached thread that already starts with its root [`338bb5c3a`](https://github.com/matrix-org/matrix-rust-sdk/commit/338bb5c3a). EXI renders it in threads as "This is the beginning of the thread." (untranslated string), in DMs too, without the predecessor-room dialogue [`084f46cc3`](https://github.com/element-hq/element-x-ios/commit/084f46cc3). Regression coverage in the thread back-pagination/filtering integration tests; the same suite run caught latent fallout from round 69's read-marker change (`test_send_read_receipts` still expected our own receipt not to move the marker) - updated to the intended behaviour.
### Thread gap infinispinner: gap-only changes suppressed (round 70)
- [ ] `bug` rust-sdk: a thread gap resolved via `/relations` entirely to already-known events changes the gaps snapshot with zero event diffs; `send_timeline_updates` suppressed empty-diff updates unless `gaps_announced` was armed, and only the chunk-loaded storage-pagination path armed it. A gap rendered from the timeline's init-time gaps pull, then resolved through an early-return path, left a stale gap item the client re-resolved forever ("resolved: false" every ~2s - a never-ending spinner at the bottom of the thread, also blocking the beginning-of-thread item from ever appearing). The gate tracked the wrong thing (observers learn about gaps by pulling, not by being told); dropped it - every gap change is announced, non-gap observers no-op on empty updates [`4e05eb7e4`](https://github.com/matrix-org/matrix-rust-sdk/commit/4e05eb7e4). Regression test hangs without the fix.
### Thread stranded rootless: all-duplicates gap resolution ditched the leading gap (round 70)
- [ ] `bug` rust-sdk: a gap page that deduplicated entirely against known events dropped its token unconditionally. Correct for a mid-chunk gap (the overlap proves it empty), wrong for the leading gap of a rootless thread: `/relations` never returns the thread root (it's only fetched once a page comes back without a next-batch token), and the leading gap's item was the client's only affordance for reaching it once the start had been claimed (storage exhausted). The thread ended permanently rootless - no gap item, no beginning-of-thread item, client told "start reached". Now an all-duplicates page on a rootless thread's leading gap keeps walking: duplicates migrate into the gap's position (ordering preserved by the same remove+reinsert used for partially-duplicated pages) and the token is followed page by page until the final page brings the root to the head [`5bc938584`](https://github.com/matrix-org/matrix-rust-sdk/commit/5bc938584). Regression test walks a leading gap through an all-duplicates page to the root and the timeline start item.
### Correction: dogfood builds 204-209 shipped a stale SDK (round 70)
- [ ] `process` The dogfood install script re-embedded a prebuilt Aug 23 `MatrixSDKFFI.xcframework` while reporting fresh SDK SHAs, so none of the SDK-side fixes in the round 69/70 sections above (read marker, UTD dedup graft, OrderTracker panic, thread timeline start, gap announce, leading-gap walk) were actually on the device before build 210; their earlier on-device observations are void and revalidation starts from build 210. The EXI Swift-side fixes were unaffected. The build script now rebuilds the xcframework every time and refuses to install on a source-stamp mismatch.
### Round 70 validated (build 210)
- [x] Build 210 - the first build genuinely carrying the round 69/70 SDK fixes (see the stale-SDK correction above; framework source-stamp and binary strings verified before install) - USER-VALIDATED: the previously-stranded thread walks its leading gap back to the root and shows "This is the beginning of the thread." The thread timeline start feature ([`338bb5c3a`](https://github.com/matrix-org/matrix-rust-sdk/commit/338bb5c3a)), the gap-announce fix ([`4e05eb7e4`](https://github.com/matrix-org/matrix-rust-sdk/commit/4e05eb7e4)) and the leading-gap walk ([`5bc938584`](https://github.com/matrix-org/matrix-rust-sdk/commit/5bc938584)) are confirmed end-to-end. The round 69 SDK fixes (read marker, UTD dedup graft, OrderTracker panic) get their first genuine on-device exposure on this build and remain under observation.
### Sliding sync: a deferred pos lost to background-refresh suspension (round 71, rageshake 7569)
- [ ] `bug:major` rust-sdk: the ack-gated deferred pos persist (disk pos never runs ahead of the event cache) could still be pending when a background refresh stopped sync and iOS suspended the process; the advanced pos never reached disk while the server had already deleted the old one, so the next launch replayed a dead pos into `400 M_UNKNOWN_POS` and a full cold sync (5s launch). `SlidingSync::flush_pending_pos`: on SyncLoopStop the loop waits (50ms polls, 5s ceiling) for the persister to write the pending pos - still never ahead of the event cache; on timeout the stale-but-safe pos stays. `SyncService::stop` awaits the loop and EXI awaits `pauseClientServices` before completing the BGTask. Test included. Upstream candidate alongside the deferred-persist work [`b1066622a`](https://github.com/matrix-org/matrix-rust-sdk/commit/b1066622a)
### Media viewer: reload rest gate, and abandoned viewers starving the main actor (round 71, builds 211-212)
- [ ] `bug` EXI: a heal `reloadData` firing while a finger rested on QuickLook's nested scroll view (or during its page-snap animation) wedged the pager - ten swipes changing nothing. Heal reloads now go through `waitUntilResting` (offset still for 4x50ms) which additionally requires no finger down anywhere on the window [`6cee95732`](https://github.com/element-hq/element-x-ios/commit/6cee95732)
- [ ] `bug:major` EXI: grid taps built viewers that never presented (one appeared ~30s later): each viewer polled thumbnails at 50ms for ~130 placeholder loops, abandoned viewers' tasks stayed alive holding self, and a burst of taps stacked ~28k main-actor wakeups/s - the presentation gate itself starved. The thumbnail wait is now event-driven (once-only continuation resumed by load/timeout/cancellation) and `cancelBackgroundWork()` runs when a viewer is dismantled, so abandoned viewers stop competing [`a444f0a49`](https://github.com/element-hq/element-x-ios/commit/a444f0a49)
### Scroll wedge after a home-indicator swipe: the stale system gesture gate (rounds 72-76, rageshakes 7549/7608/7625/7626)
The long-chased "timeline/room list stops scrolling" wedge, root-caused: a home-indicator swipe starting over the app hands its touch to the system, and `_UISystemGestureGateGestureRecognizer` on the window never gets its end-of-event reset - it sits in `.ended`/`.failed` with no touches indefinitely (it survives backgrounding and hour-long suspensions), gating touches-began for everything beneath it, so scroll-view pans never begin. Row rebuilds (new messages, modal round-trips) cure it by rebuilding recogniser relationships, which is why it always "fixed itself".
- [ ] `diag` EXI: the wedge self-heal reloads the wedged table and dumps every recogniser between the hit view and the window (`Class@View:state/enabled/touches`) - the chain dump that produced the root cause; the reloadData cure it added did not work (fired 8x during the 2026-08-28 wedge with no effect) [`dd417146d`](https://github.com/element-hq/element-x-ios/commit/dd417146d)
- [ ] `bug:critical` EXI: on wedge detection, reset every enabled recogniser stuck in a terminal state with zero touches by toggling `isEnabled` [`9f2ca4a4b`](https://github.com/element-hq/element-x-ios/commit/9f2ca4a4b)
- [ ] `bug:critical` EXI: rageshake 7608 (13 heals over 11 minutes) proved the `isEnabled` toggle alone never resets the gate, and that the wedge can predate the foreground entirely. The reset now drives the state machine directly (`state = .failed`) and logs the post-write state, and the same sweep runs over all windows on every `didBecomeActive`, clearing stale gates before the first touch [`30808a5b1`](https://github.com/element-hq/element-x-ios/commit/30808a5b1)
- [ ] `bug` EXI: dead drags landing on message bodies never tripped the heal - `MessageTextView` is a UITextView (a UIScrollView with `isScrollEnabled = false`) and the ancestor walk picked it as the hit scroll view, resetting the wedge counter every other touch (rageshake 7626). The walk now skips scroll-disabled scroll views [`75660dfc0`](https://github.com/element-hq/element-x-ios/commit/75660dfc0)
### Latest events: bundled thread UTDs downgrading decrypted previews (round 73, rageshakes 7588/7589)
- [ ] `bug:major` rust-sdk: a gappy sync of a thread root builds the root's bundled `latest_event` as a UTD (servers can only bundle ciphertext) and saving it overwrote the already-decrypted store copy of the thread reply, downgrading the room-list preview to "Waiting for this message" until the room was opened. The bundled save is now skipped when it is a UTD of an event the cache already knows decrypted; regression test included. Upstream candidate [`57b2a36d0`](https://github.com/matrix-org/matrix-rust-sdk/commit/57b2a36d0)
- [ ] `bug` rust-sdk: secondary gap found on the way - the redecryptor's heal was dropped for rooms not yet registered at cold start; registering a room now recomputes its restored unable-to-decrypt latest-event values [`7f0888734`](https://github.com/matrix-org/matrix-rust-sdk/commit/7f0888734)
### Dogfood infrastructure: sandbox pushes for dev-signed Release builds (round 74)
- [ ] `infra` EXI: dev-signed Release builds carry a sandbox APNs token but registered the `.ios.prod` pusher (`pusherAppID` only switches under `#if DEBUG`), so after a delete+reinstall no push ever launched the NSE (no notifications, no nse logs since 2026-08-24). `#if DEBUG || DOGFOOD_SANDBOX_PUSH` around the pusher id; the dogfood build script passes the flag. Worth an upstream look: any dev-signed Release install has silently dead pushes [`85a9c41b5`](https://github.com/element-hq/element-x-ios/commit/85a9c41b5)
### Stragglers from earlier rounds
- [ ] `qol` EXI: push-tap and deeplink routes navigate without animation (the user arrives at the target, not at a slide from the room list), and a permalink presents the room screen as soon as the room proxy resolves - in its focused-timeline loading state - instead of holding the user behind a Loading modal on the room list [`1dda0a80c`](https://github.com/element-hq/element-x-ios/commit/1dda0a80c)
- [ ] `infra` EXI: TimelineKind made nonisolated (its computed properties are read from the nonisolated timeline item builder); companion of the thread timeline-start item [`9cd090513`](https://github.com/element-hq/element-x-ios/commit/9cd090513)
- [ ] `bug` rust-sdk: message-types focused timelines dropped every local echo, so an unsent photo sat in the room timeline while the media viewer hit its end right before it; a local echo whose msgtype matches is now appended once the window is at the newest end, and the remote echo replaces it as usual [`391bbbd93`](https://github.com/matrix-org/matrix-rust-sdk/commit/391bbbd93)
Contributor guide
No contributing guide indexed for this repository
Research direction
Start with docs/dogfood-branches.md and choose one unchecked item rather than treating this as a single change. Follow its commit and issue links to determine whether the work belongs in matrix-rust-sdk or element-x-ios, then validate the change through the owning repository's normal tests and PR review; done means one task is implemented and its checkbox is updated.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- ios, rust, swift
- Domain
- backend-api-design, mobile-dev, performance
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Needs clarification
- Newbie friendliness
- 25/100