Skip to content

feat(chats): keep pins beyond the server limit on the device - #262

Open
TiaraBasori wants to merge 3 commits into
iebb:masterfrom
TiaraBasori:feat/local-pinned-chats
Open

TiaraBasori wants to merge 3 commits into
iebb:masterfrom
TiaraBasori:feat/local-pinned-chats

Conversation

@TiaraBasori

@TiaraBasori TiaraBasori commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Change

Adds a device-only pin for the chat list, reachable through a new Advanced settings switch (“Unlimited pinned chats”, off by default). Telegram caps pinned chats per account (5, 10 with Premium, in the main list). With the switch on, a pin that the server would refuse — the account is already at its cap, or the chat has no main-list position yet — is kept on this device instead: no toggleChatIsPinned request is sent, the chat sorts above the server pins with the usual pin icon and pinned-row colour, and the entry is stored in this app's own preferences, scoped to the account.

This is not a sync trick: nothing is written to Telegram and nothing is read back from it. Pinning and unpinning within the server's limit keeps behaving exactly as before (the same request, byte for byte), and so does the whole feature with the switch off. A device-only entry is dropped when the user unpins it, when the chat leaves the main list (archived, deleted, left), or when Telegram itself pins the chat — the server pin then owns the row at the server's own order.

Behaviour details:

  • Ordering: device-only pins occupy a band above the highest server order seen. Within the band the most recently pinned chat is first; the mapping is rebuilt from the stored entries only, so recomputing it never reshuffles rows. The comparator additionally keeps a device-only pin above server pins (and the live position may only raise its order, never lower it), so a band restored against a list whose server pins have since moved cannot sink.
  • The cap comes from the pinned_chat_count_max option (default 5 until it answers; a value <= 0 means the server does not cap pins and then every pin stays a normal server pin).
  • When the limit is hit with the switch off, the failure notice now points at the switch instead of only reporting the limit.
  • Switch off: no preference write, no band order, no stored entry; the pin request is the one the app sent before this change.

Affected platforms: all (shared chat-list code; the entry is in the shared Advanced settings page).

Telegram API Terms classification

Follow the contribution and agentic review rules. Select exactly one classification and explain it below.

This is an agentic source-code review classification. Every store version must remain compliant with applicable Telegram API Terms; the guarded-source exception does not apply to store distribution or review.

  • Compliant/unchanged: this change complies with applicable Telegram API Terms, preserves Telegram's basic functionality, and does not introduce behavior covered by section 1.4.
  • Section 1.4 exception: the entire feature is guarded by allowTelegramTosViolations (I_DONT_FUCKING_CARE_ABOUT_TOS), disabled in official builds, and available only in an explicitly enabled self-build.
  • Disallowed or unresolved: this PR must not be approved or merged until its classification and implementation are resolved.

Reason and affected Telegram behavior:

Section 1.4 forbids interfering with Telegram's basic functionality: acting on the user's behalf without consent, preventing self-destructing content from disappearing, tampering with read/online/typing statuses. This change touches none of that. It does not send anything to Telegram, does not forge a server-side state, and does not suppress or alter any status. It is a purely client-side ordering preference for the user's own device: the chat is marked with this app's pin styling and placed at the top of this device's list, while Telegram's own pinned chats are loaded, counted and displayed exactly as before. The chat is never reported to Telegram as pinned, and other devices do not see the pin. toggleChatIsPinned is still sent for every pin the server can accept, and unpinning a server pin is still a server operation.

Basic checks (every PR)

Check each item after verification. If a check is inapplicable, explain why in the evidence below before checking it.

  • I reviewed the diff for correctness, unintended behavior, secrets, and unrelated changes.
  • Formatting passed, or I recorded why it is inapplicable: dart format --output=none --set-exit-if-changed lib test integration_test test_driver.
  • Analysis passed, or I recorded why it is inapplicable: flutter analyze.
  • Relevant tests passed, or I recorded why they are inapplicable; name the commands and test cases below. Quality CI runs flutter test --concurrency=4.
  • I checked affected build/platform paths and applicable localization/generated files; list the evidence below.
  • I completed the Terms classification above and verified that official builds cannot enable section 1.4 exceptions.

Commands, results, platform/build/UI evidence, and reasons for inapplicable checks:

Toolchain: Flutter 3.44.2 (/tmp/flutter-3.44.2, the version Quality CI pins). This head integrates master through 3c317d0c and carries three commits: 9e7ec66f (ownership guards), 9c4d69aa (merge of origin/master) and b778fc62 (projection ownership: the position projection and the switch-off release walk own the store's account scope before they read it).

Formatting:

dart format --output=none --set-exit-if-changed lib/chats/chat_list_view_model.dart
  lib/chats/local_pin_store.dart test/local_pins_test.dart test/local_pin_settings_test.dart
→ Formatted 4 files (0 changed), exit 0

Analysis:

flutter analyze lib/chats/chat_list_view_model.dart lib/chats/local_pin_store.dart
  test/local_pins_test.dart test/local_pin_settings_test.dart
→ No issues found!

Tests (serial, CI toolchain):

flutter test --no-pub -j 1 test/local_pins_test.dart                      → 18 tests passed
flutter test --no-pub -j 1 test/local_pin_settings_test.dart              → 1 test passed
flutter test --no-pub -j 1 test/chat_folder_mark_read_owner_test.dart     → 5 tests passed
flutter test --no-pub -j 1 test/chat_folder_mark_read_test.dart           → 8 tests passed

The last two are #246's owner-pinned bulk-read suites, run on the merged tree. git diff origin/master...HEAD -- lib/chats/chat_list_view_model.dart contains no line touching _bulkReadGeneration, _pinWriteOwner, _writeOwnerIsLive, _readChatListPage, _markChatListsRead or clientForTesting, so master's implementation came through the merge verbatim; the only textual conflict in that file was dispose(), where both sides' work is kept.

test/local_pins_test.dart covers the store (pin/unpin/prune, account scoping, and the three ordering invariants: locals above every server order, newest local first, idempotent band), plus the model through a fake TDLib transport: a pin past the cap sends no toggleChatIsPinned and lands the row first; the same gesture again releases it and restores the chat's own order; a server position that pins the chat drops the local entry; the switch off keeps the pre-existing request byte-identical and writes no preference. test/local_pin_settings_test.dart drives the Advanced settings row (cap in the subtitle, toggle persists, toggle off clears the preference).

Ownership boundaries (new at this head):

  • a pin pass stops when the account changes under its awaited unpin — two device-only pins whose chats an updateChatPosition now reports server-pinned. The first unpin's preference write is held with LocalPinStore.debugUnpinBarrier, the shared store is re-scoped to an account that shares the same chat ids without touching the original model's meId, then the write is released; both of the replacement account's preference keys and both entries in memory survive. This is the case the previous head missed: the old pass passed its own epoch check because only the shared store had moved.
  • disposal stops the pin pass before its next write — the same pass disposed while the first write is parked: the entry it had not reached stays stored and no row mutation is scheduled.
  • a stale expected generation cannot unpin the replacement account — a direct unpin(id, expectedGeneration: stale) after the re-scope: the replacement's in-memory entry and its preference value are both untouched, and a fresh-generation unpin then succeeds.
  • a delayed pin-cap answer from the old account does not replace the new one — the first getOption is held, the account is replaced and reads 9, then the old answer (3) lands: the cap stays 9.
  • a position that lands while the store is re-scoped must not project a foreign band onto this list (new at b778fc62) — another live list re-scopes the shared store while the original model's meId is left untouched; a live updateChatPosition for a chat id the replacement account also pins must neither pin this list's row nor move its order. Mutation: stashing the _storeIsOwnAccount term in _applyPosition fails it with Expected: false / Actual: <true> ("a re-scoped store must not pin this list's row").
  • turning the switch off while the store is re-scoped releases nothing on this list (new at b778fc62) — the switch-off release walk owns the account scope before iterating pinnedChatIds: with the store still scoped to the other account, setUnlimitedEnabled(false) neither releases this list's rows nor drops the replacement account's stored entry.

Effect points, so the guard is not only at the cited lines: LocalPinStore.unpin/pin/pruneTo reject an already-stale expectedGeneration at entry and re-check after every await (barrier, preference write) before the in-memory mutation, the notification and the next iteration; _loadAccountEntries is the single owner of the generation bump; ChatListViewModel._ownsPinPass(epoch, generation) additionally requires the store to still be scoped to this model's meId and is re-checked before every unpin, before each row mutation and before the resort; _pruneLocalPins and _togglePinUnlock thread the same generation; the meId setter no longer re-scopes a shared store another live list already scoped to the same account; _refreshPinCap captures _pinEpoch + loadGeneration at request start and drops a delayed answer that lost either.

Mutation checks, quoted: removing the entry-level generation check in unpin fails the first case with Expected: true / Actual: <false> at test/local_pins_test.dart:782 ("a stale generation must not remove the replacement entry"); leaving the check only after _entries.remove (the reviewer's cited shape) fails it the same way; removing the _ownsPinPass gate in _refreshPinCap fails the cap case with Expected: <9> / Actual: <3>. All three were reverted and the suite re-run green.

Localization/generated files: two new keys (chatListLocalPinEnabled, chatListLocalPinLimitReached) through all three layers — lib/l10n/app_localizations.dart, translations/strings/<8 locales>/strings.xml, regenerated assets/l10n/*.json. On the merged tree: python3 translations/tools/check.py (8 locales x 3936 keys, OK), python3 translations/tools/gen_assets.py --check (9 locale assets are up to date), python3 tool/check_l10n_strings.py (No visible UI string literals outside lib/l10n). The eight conflicted assets/l10n/*.json were regenerated from the merged canonical XML rather than resolved by picking one side.

Build/platform paths: Dart-only change; no new plugin, asset, permission, or native code, so the iOS/Android/macOS/Windows/Linux build paths are untouched. No store build or packaging change.

Line counts (git diff --numstat origin/master...HEAD, head b778fc62):

   1    1  assets/l10n/*.json (8 files)
 511    3  lib/chats/chat_list_view_model.dart
 395    0  lib/chats/local_pin_store.dart
   2    0  lib/l10n/app_localizations.dart
   5    0  lib/main.dart
  57    0  lib/settings/advanced_settings_view.dart
 103    0  test/local_pin_settings_test.dart
1170    0  test/local_pins_test.dart
   2    0  translations/strings/*/strings.xml (8 files)

Section 1.4 exception checks

Complete every item for an exception. For a compliant/unchanged PR, write Not applicable here.

Not applicable

Agentic reviewer confirmation

Agentic code reviewer only: record this confirmation in the PR review or check these boxes before approval.

  • I independently confirmed the Terms classification and inspected all required guards and evidence.
  • Applicable checks and Quality CI pass; no unresolved classification or unguarded section 1.4 behavior remains.

@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Automated PR review

Head: 65996b16f81e1712ce2a2e37f016fb202041dec8 · master: 82397aef906f40e9034e062ae56eb3a3640efe6a
LLM recommendation: needs_human. This is not a GitHub approval.
Auto-merge: blocked. Only iebb can authorize the current head.

Review unavailable: Waiting for the exact-head Quality run before using the free-model quota.

Remaining gates

  • Waiting for iebb to approve this exact current head.
  • A reviewer still requests changes; obtain a superseding review or dismiss it explicitly.
  • The latest exact-head Quality PR run must pass.
  • All current head checks must complete successfully (no skipped/neutral substitutes).

No PR code runs with the model key or write token. Free-only routing; provider/rate-limit failures never authorize a merge. Existing review findings must be resolved, not hidden by a new recommendation.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-19T11:08:28.845294Z 12c9a28 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@iebb iebb left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

[P1] Keep the asynchronous local-pin synchronization bound to its original account.

Reviewed the current head as a legitimate improvement. In lib/chats/chat_list_view_model.dart:1004-1019, _syncLocalPinState snapshots adopted ids, awaits store.unpin(id), then continues using LocalPinStore.shared without checking _pinEpoch, account ownership or disposal. Meanwhile the meId setter changes that shared store to the replacement account. With two adopted ids and a preference write still pending, switching accounts between iterations makes the next unpin operate on the replacement account's keys; the final loop can also project that account's entries into the old model.

Please pin an immutable account owner/generation for this pass and stop before subsequent preference writes or row mutations after an ownership change/disposal. Include a faithful delayed-preferences regression that switches A to B after the first awaited unpin, uses colliding chat ids and verifies B's pins are untouched; also cover disposal. Store-only synchronous account-scoping tests do not exercise this boundary. This is a static production-path trace, not a claim that a new test was executed. Leave this legitimate PR open for revision.

@TiaraBasori
TiaraBasori force-pushed the feat/local-pinned-chats branch from 12c9a28 to 4d40eff Compare October 21, 2026 09:39
@TiaraBasori

Copy link
Copy Markdown
Contributor Author

Addressed at head 4d40eff.

[P1] The pass is now bound to one store scope. _syncLocalPinState captures LocalPinStore.shared.loadGeneration and _pinEpoch at start, gates every iteration with ownsPass() => !_disposed && _pinEpoch == epoch, and stops before the row mutation and the resort when either moves. LocalPinStore.unpin takes expectedGeneration: it removes the entry from its in-memory map for the scope it started in, then abandons the preference write and the notification when the store was re-scoped under it, because the key it computed addresses the replacement account. setActiveAccountUserId and _loadAccountEntries bump the generation.

Pinned by test/local_pins_test.dart (13/13):

  • a pin pass stops when the account changes under its awaited unpin — two device-only pins whose chats a position update reports server-pinned. The first unpin's preference write is parked with LocalPinStore.debugUnpinBarrier, the store and the list switch to an account that shares the same chat ids while that write is in flight, then the write is released; both replacement keys survive and the store still reports its own pins.
  • disposal stops the pin pass before its next write — the same pass disposed while the first write is parked: the entry the pass had not reached stays stored, and no row mutation is scheduled.

Mutation checks, quoted: removing the per-iteration gate and the expectedGeneration argument makes the first case fail with Expected: not null, Actual: <null> (the stale pass deleted the replacement account's key) and the second with Expected: true, Actual: <false>.

Format Formatted 3 files (0 changed), analyze No issues found!, l10n trio passes (8 locales x 3932 keys, 9 assets up to date).

@iebb iebb left a comment •

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Re-reviewed 4d40eff. The same-model meId-switch/disposal regression is substantially improved, but [P1] the captured store generation still is not an ownership guard before effects. _syncLocalPinState captures it at lib/chats/chat_list_view_model.dart:1004 but ownsPass() at 1007 only checks this model's epoch. LocalPinStore.unpin at lib/chats/local_pin_store.dart:219-237 removes the current entry and writes the current captured user key before comparing expectedGeneration. If another live account-scoped ChatListViewModel re-scopes the singleton while the old pass awaits, this model's epoch need not change; its next unpin removes the replacement account's colliding id before the late generation check. Check generation/owner before every in-memory removal, preference write and projection; reject an already-stale expectedGeneration at entry. Add a regression that re-scopes the shared store without setting the original model's meId, and a direct stale-generation unpin case asserting both replacement memory and preferences survive. Also bind _refreshPinCap (870-880) to its epoch/owner: a delayed old-account response currently assigns its cap to the replacement account. These are source-traced remaining boundaries, not a claim that new tests were run locally. The PR remains a legitimate improvement, but account isolation is required before approval.

Integration note against now-current master d411c13: a read-only merge-tree check also confirms conflicts in lib/chats/chat_list_view_model.dart and the eight generated assets/l10n JSON catalogues. After fixing ownership, integrate current master while preserving #246's owner-pinned bulk-read implementation; regenerate the shared assets from the reviewed canonical XML instead of selecting one side's generated catalogue. No conflict resolution or app validation was run locally.

@TiaraBasori

Copy link
Copy Markdown
Contributor Author

Addressed at head 159f1d2a (two commits: 9e7ec66f ownership guards, 159f1d2a merge of current master 2306d2ae).

[P1] The generation is now the guard, before every effect. LocalPinStore.unpin, pin and pruneTo reject an already-stale expectedGeneration at entry — before the in-memory removal, before the preference write, before the notification — and re-check it after every await (the debug barrier, the preference write) before the next mutation and the next loop iteration. pruneTo re-checks per iteration, so a mid-pass re-scope cannot drop the replacement account's colliding ids. _loadAccountEntries is the single owner of the generation bump.

In the model, _ownsPinPass(epoch, generation) now requires three things — !_disposed, _pinEpoch == epoch, and store.loadGeneration == generation and the store still scoped to this model's meId (no account known ⇒ it owns nothing). It is checked before every unpin, after each unpin, before each row mutation and before the resort; a refused unpin no longer mutates the row. _pruneLocalPins and _togglePinUnlock thread the same generation, the meId setter no longer re-scopes a shared store that another live list already scoped to the same account, and _refreshPinCap captures _pinEpoch + loadGeneration at request start and drops a delayed answer that lost either.

Regressions (test/local_pins_test.dart, 13 → 16):

  • a pin pass stops when the account changes under its awaited unpin — your repro: the shared store is re-scoped to an account with colliding chat ids without touching the original model's meId, the first unpin's preference write is parked, then released. Both replacement entries and both mithka.localPins.v1.user<id>.* keys survive. The previous head passed its own epoch check here precisely because only the store had moved.
  • a stale expected generation cannot unpin the replacement account — a direct unpin(id, expectedGeneration: stale) after the re-scope leaves both the replacement's memory entry and its preference value untouched; a fresh-generation unpin then succeeds.
  • a delayed pin-cap answer from the old account does not replace the new one — the first getOption is held, the account is replaced and reads 9, then the old 3 lands; the cap stays 9.
  • disposal stops the pin pass before its next write — kept green.

Mutation checks, quoted: removing the entry-level check fails the first case with Expected: true / Actual: <false> at test/local_pins_test.dart:782 ("a stale generation must not remove the replacement entry"); leaving the check only after _entries.remove — the shape you cited — fails it identically; removing the _ownsPinPass gate in _refreshPinCap fails the cap case with Expected: <9> / Actual: <3>. All reverted, 16/16 green.

Master integration. Merged --no-ff; the conflicts were exactly the nine files you listed. lib/chats/chat_list_view_model.dart: #246's owner-pinned bulk read came through verbatim — git diff origin/master...HEAD on that file contains no line touching _bulkReadGeneration, _pinWriteOwner, _writeOwnerIsLive, _readChatListPage, _markChatListsRead or clientForTesting; the only textual conflict was dispose(), resolved by keeping both sides. #246's own suites pass on the merged tree (chat_folder_mark_read_owner_test 5/5, chat_folder_mark_read_test 8/8). The eight assets/l10n/*.json conflicts were regenerated from the merged canonical XML (gen_assets.py), not side-picked; the l10n trio is green: 8 locales x 3936 keys OK, 9 locale assets up to date, no visible UI literals outside lib/l10n.

Format Formatted 4 files (0 changed), analyze No issues found!, test/local_pins_test.dart 16/16, test/local_pin_settings_test.dart 1/1. The PR body's ownership-evidence section and line counts were regenerated at this head.

@TiaraBasori TiaraBasori left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

At new head b778fc62 — an increment on top of the 9c4d69a rebase (which already carried the pre-fix 9e7ec66f), no baseline change: every in-memory removal, preference write and projection now re-checks the captured store generation/owner.

What the head already proved red (mutated, then re-run green):

  • Remove before the late check in LocalPinStore.unpin (lib/chats/local_pin_store.dart): the old shape deleted the current entry and wrote the current captured user key before expectedGeneration was compared. Now a stale expectedGeneration is refused at entry (if (!_ownsGeneration(expectedGeneration)) return false; before any mutation), the generation is re-checked after the barrier, after the preference write, and before notifyListeners. Stashing lib/chats/local_pin_store.dart fails a directly stale generation is refused before the store mutates with Expected: true / Actual: <false> ("a stale generation must not remove the replacement entry") — the exact data-loss boundary named.
  • Shared store re-scoped without this model's meId moving in _syncLocalPinState (lib/chats/chat_list_view_model.dart): a second live account-scoped ChatListViewModel re-scopes the singleton while the first pass is parked on debugUnpinBarrier; _ownsPinPass(epoch, generation) now requires store.loadGeneration == generation && _storeIsOwnAccount && !_disposed and is re-checked before every unpin, before each row mutation and before the resort, so the late pass cannot drop the replacement account's colliding id (regression a pin pass stops when the account changes under its awaited unpin and a re-scoped store survives a parked unpin from a list that still believes it owns the account — the original model's meId is deliberately left untouched in the second one).
  • Delayed _refreshPinCap (the cited 870-880 region, now ~993): epoch/generation are captured before the getOption await and re-checked before _pinCap is assigned; removing the gate fails a delayed pin cap does not land on the replacement account with Expected: <9> / Actual: <3>.

This increment (b778fc62, 2 files, +164/-2) closes the two projections that still read the shared store without owning its scope — the same class, not the cited lines:

  • _applyPosition's inline pin branch: isLocal is now _storeIsOwnAccount && store.unlimitedEnabled && store.isLocallyPinned(id). A live updateChatPosition for a chat whose id collides with a pin the replacement account holds used to read that foreign entry and pin this list's row on the foreign band order. New regression a position that lands while the store is re-scoped must not project a foreign band onto this list drives the re-scope with the original model's meId untouched, then delivers the position: without the gate it fails with Expected: false / Actual: <true> ("a re-scoped store must not pin this list's row"); with it the row keeps isPinned: false and its live order, and the replacement account's memory + preference key both survive.
  • _handleLocalPinStoreChange (the switch-off release walk): if (!_storeIsOwnAccount) return; before iterating store.pinnedChatIds. The walk previously projected whatever account's ids the store held onto this list's rows (ids collide across accounts), releasing rows this list never held. New regression turning the switch off while the store is re-scoped releases nothing on this list keeps the store scoped to the other account while setUnlimitedEnabled(false) fires: this list's row keeps its order and the replacement account's stored entry survives.

Gates re-run at this head with CI's toolchain (/tmp/flutter-3.44.2), serially: flutter test --no-pub -j 1 test/local_pins_test.dart → 18 tests passed (was 16; the two new projections included), flutter test --no-pub -j 1 test/local_pin_settings_test.dart → 1 passed, flutter test --no-pub -j 1 test/chat_folder_mark_read_owner_test.dart → 5 passed; flutter analyze --no-pub lib/chats/chat_list_view_model.dart lib/chats/local_pin_store.dart test/local_pins_test.dart → No issues found; dart format --output=none --set-exit-if-changed on the four touched files → 0 changed; the l10n trio stays green.

On the integration note: against current master (82397aef, ahead of the 3c317d0 the 9c4d69a merge integrated) a read-only git merge-tree --write-tree --name-only 82397aef b778fc62 now conflicts only in the eight generated assets/l10n/*.json — lib/chats/chat_list_view_model.dart and every translations/strings/**.xml auto-merge, and git diff origin/master...HEAD -- lib/chats/chat_list_view_model.dart still contains no line touching _bulkReadGeneration, _pinWriteOwner, _writeOwnerIsLive, _readChatListPage, _markChatListsRead or clientForTesting, so #246's owner-pinned bulk-read implementation is preserved verbatim. The generated-catalogue regen merge is intentionally not bundled into this ownership increment; it lands next so this head stays reviewable as a pure ownership delta.

With the unlimited-local-pins switch on, a pin the server would refuse (the
cap is reached, or the chat has no position) is kept on this device only:
the entry is account-scoped in the preference store, the row sorts above
every server pin, and nothing is sent to Telegram.

The sync pass that adopts server pins and re-applies the local band now
belongs to one store scope: it captures the store's load generation and the
list's pin epoch, gates every iteration on them, and hands the generation to
unpin, which abandons its preference write if the store was re-scoped under
it. A disposal stops the pass before its next write. Two colliding accounts
can therefore never have one account's unpin land on the other's keys.

Regressions hold the first unpin's preference write open, switch to an
account that shares the same chat ids, release it, and assert both of the
replacement account's keys survive; the disposal variant disposes the list
with the write in flight and asserts the entry it had not reached is
untouched.
The store is one singleton shared by every live chat list, so a pass that
spans an await can be re-scoped by another list without this model's meId
changing. The epoch-only ownsPass() therefore stayed green after such a
re-scope, and unpin() removed the colliding entry and wrote the captured
account's key before comparing expectedGeneration.

Every mutator now checks the captured generation before each effect — the
in-memory removal, each preference write and each notification — and an
already-stale expectedGeneration is refused at entry. pin() and unpin()
return whether they applied. The sync pass's ownsPass() includes the store
generation and the store/account ownership, and is re-checked before each
row mutation and before the resort; _pruneLocalPins and the toggle gesture
hand the generation through and gate the same way; meId no longer reloads a
store another list already scoped to the same account. _refreshPinCap
captures the epoch and generation at request start and drops a delayed
answer that belongs to a superseded account.

Regressions: a re-scope of the shared store during a parked unpin with the
original meId left untouched (both the replacement entry and its key
survive), a directly stale expectedGeneration refused before any mutation
while a fresh one still applies, and a parked pin-cap answer dropped after
the account is replaced.
@TiaraBasori
TiaraBasori force-pushed the feat/local-pinned-chats branch from b778fc6 to 5ff57ba Compare October 24, 2026 14:35
The review's remaining class: the sync pass, the toggle and the prune
all re-check the store's generation now, but two projection paths still
read the shared store without owning it. A position that lands while the
store is scoped to another account read that account's entry for the
same chat id and pinned this list's row on the foreign band order, and
the switch-off release walked the store's pinned ids — another
account's, whose ids collide — and released rows this list never held.

Both projections now require _storeIsOwnAccount before they read the
store, exactly like every other local-pin effect. The model's meId is
deliberately left untouched by the regressions, so only the ownership
check can keep the foreign band off this list's rows.

Regressions: a main-list position arriving while the shared store is
re-scoped to an account holding a pin for the same chat id must not pin
this list's row nor move its order, and turning the switch off while
the store stays scoped to the other account must neither release this
list's rows nor drop the replacement account's stored entry.
@TiaraBasori
TiaraBasori force-pushed the feat/local-pinned-chats branch from 5ff57ba to 65996b1 Compare October 24, 2026 14:45

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants