test(pro): seeded Pro accounts, over-limit pins, and the Clear All Data warning - #165
Merged
Conversation
Pro-specific copy on an irreversible flow, read by nothing until now. It is the only thing telling a subscriber their Pro does not survive the wipe, and it is a second-stage dialog: opening Clear Data shows the generic copy, pressing Clear re-renders the same slot with the warning, and only a further press deletes anything. Every case here reads the copy and cancels - the destructive action is never taken. Desktop gets all four cells of the grid the app actually branches on (`deleteMode` crossed with `useCurrentUserHasPro()`). Each Pro case asserts two runs of copy because neither alone identifies a cell: the transfer warning is word-for-word identical in both Pro tokens, and the network branch's opening sentence is word-for-word `clearDeviceAndNetworkConfirm`, the standard copy. Android gets the device branch only, and no standard-account control - `SettingsViewModel` calls `clearDataDeviceOnly()` straight from the first Clear press for a standard account, so the control would wipe the app rather than assert anything. iOS gets nothing. See below. `localizedRuns` is new and is the reason these assert runs rather than tokens. `tStripped` collapses a `<br/>` to a single space while every client renders one as no character at all, so the stripped string is a run of text that exists nowhere in the UI and can never match. Both Pro tokens span two breaks. This splits the raw token on its breaks and returns the stripped runs, so the assertion still comes from the localizer rather than a hardcoded English string. Two client-side tag gaps this could not work around, both worth fixing: - iOS has no test identifier anywhere in the flow. The settings row is a `ListItemInfo` built with no `accessibility:`, and `NukeDataModal` sets none on its radios, its buttons or its labels; the `ConfirmationModal` carrying the copy gets only an `accessibilityLabel`. Nothing here is addressable by identifier, so there is no iOS spec. - Android's two clear-data radios are `RadioOption`s built with no `qaTag` in `SettingsScreen.kt`, so the network branch cannot be selected. Device-only is the default selection, which is the only reason that half is reachable. The Android Clear/Cancel ids are the English display strings, because `AlertDialog` falls back to a button's own text when the call site gives it no `qaTag`. Real ids rather than text matches, but ones that would move with the locale - noted on the locators. Verified: 4/4 desktop (12.0s), 1/1 android (30.1s).
qa-seeder removed that state key. Pro access is now an option addressed by a
user's index, so the same account is built as '1user' plus `{ pro: { 0: {} } }`
- empty terms being the seeder's defaults, 30 days out and auto-renewing, which
is exactly what the key granted.
`stateKey` reaches `buildStateForTest` through `openSeededWindows`, so the
options had to be threaded through it.
Needs qa-seeder 0.4.0: under 0.x a caret range confines ^0.3.0 to 0.3.x, and
this API is not in 0.3.0.
The clients enforce STANDARD_PIN_LIMIT and silently refuse the sixth pin, so an account holding more cannot be built by tapping - yet it occurs in production, because config arrives from a linked device or a restore and the pins may have been made while the account was Pro elsewhere. The seeder writes the priorities into config directly, which is the only way to reach the state the limit is supposed to govern. `open_Alice1_with_contacts` takes `pins` and returns `pinnedNames`, resolved against the same user list the seeder pinned in, so no spec does index arithmetic. The new spec covers a never-Pro account arriving with six pins: all six honoured and above the unpinned rest, a seventh refused, one unpinnable, and the freed slot still unusable at five. One assertion - that the client honours the sixth rather than clamping its display to five - could not be verified against an app checkout and is marked in the file as needing a decision rather than being weakened to pass. Also adds a skipped stub for a message one character past the Pro limit, which needs a seeder action that does not exist yet.
The clients now tag what this needed, so the mobile spec becomes `bothPlatformsIt` and gains the network branch and a control. iOS was previously unreachable: the settings row carried no identifier and neither did anything in `NukeDataModal`. Android's two radios had no `qaTag`, so only the device branch - the default selection - could be reached. Both are fixed in the client repos; nothing here works around them. One thing that only shows up on a device. The two stages are the same dialog on Android, whose text swaps, but two STACKED modals on iOS: the confirmation is presented over `NukeDataModal` rather than replacing it, so both sit in the accessibility tree at once. Giving them the same identifiers had the locator match whichever came first - the one underneath - and all three iOS specs failed asserting the confirmation copy while reading the pre-confirmation copy. Hence `ClearDataDialogDescription` and the iOS half of `ClearDataConfirmButton`, which name the first stage only. Adds a standard-account control on the NETWORK branch. There cannot be one on the device branch: both clients delete straight from the first Clear press there, with no confirmation to read. Verified on all three: iOS 3/3 (31.5s, 31.7s, 31.1s), Android 3/3 (29.7s, 28.3s, 28.4s), Desktop 4/4 (18.2s). The iOS run needed a local build of the client change - the prebuilt app predates it.
An id says the client rendered the right control; only the copy says it rendered the right words in it, and the two fail independently - a control keeps its identifier through a copy change, so an id-only lookup stays green against a wrong, empty or swapped string. On a flow whose two actions are Clear and Cancel that is the difference between reading a warning and wiping the account. So every control this spec presses is now checked both ways, and CLAUDE.md carries the rule for everything after it. Where the copy lives is per-platform, and finding that out took a device: - iOS puts it on `label`; an identifier becomes the element's `name` and displaces the display text. - Desktop takes `text`, already a substring match. - Android Compose CONTROLS report no text of their own - the label is a child node, so the node addressed by id has nothing to compare. Verified from a page-source dump: the Clear button resolves to a node with no text, while a sibling `TextView` carries "Clear". Only text-bearing nodes (a dialog body, a heading) can be checked in place. `expectControlCopy` does that split and skips loudly on Android rather than passing quietly. Where an Android id is itself derived from the display string - `AlertDialog` falls back to a button's own text with no `qaTag` - the id lookup already covers the copy, and the comment says so. Also corrects the standard-account note. Desktop confirms for every account on both branches; Android and iOS confirm on both branches only for a PRO account, and send a standard account's device-only clear straight to deletion. Confirmed on Android by running that case once - the app came back on the onboarding screen. The iOS half of that is read from `clearDeviceOnly()`, not observed. Verified with the assertions in place: iOS 3/3, Android 3/3, Desktop 4/4.
Two the previous commit wrote the rule for and then did not follow. `ClearDataMenuItem` was tapped on its id alone, three call sites and no copy check anywhere - and it is the case the rule exists for, since its id is a hand-written tag rather than the display string, so the lookup says nothing about what the row reads. `ClearDeviceOnlyRadio` was used zero times: added speculatively and never wired up. It now carries its half of the first-stage assertion, so both branches the dialog offers are checked rather than only the one a given case goes on to tap. The preselected radio is never pressed, so nothing else would have read it. Each locator's doc now says whether its id carries the copy, so the next caller does not have to work it out: hand-written tag (the row), slug (the radios), or derived from the display string (Android's dialog buttons, where the lookup already covers it). Verified: iOS 3/3 - the platform these assertions actually bite on, since Android's Compose labels are child nodes and `expectControlCopy` skips there.
0.4.0 is published but uninstallable: it depends on @session-foundation/session-protos@^0.0.1, which was published, unpublished, and so can never be republished - and a caret on a 0.0.x version pins that exact patch, so nothing can satisfy it. 0.4.1 asks for ^0.0.2.
Drops the three standard-account cases, leaving two per platform. The mobile one was written against behaviour that is being removed: both clients used to delete straight from the first Clear press on the device branch, and now confirm for every account as Desktop always has. Testing the old shape would have been testing something on its way out. The two desktop ones were duplicates. `clearDataOnWindow` in `linked_device_group.spec.ts` already walks the same modal on a standard account and asserts `clearDeviceDescription` on the confirmation, via `checkModalStrings` - which is an exact match, so it also rules the Pro copy out. What that costs, stated so it is a decision and not an oversight: nothing left here separates "shows the Pro copy to Pro users" from "shows the Pro copy to everyone". Worth a control once the mobile behaviour has settled, and cheap to add then. Verified: iOS 2/2, Android 2/2, Desktop 2/2.
`1userWithProAccess` is gone in 0.4.1, replaced by a `pro` option that composes with every state. That is the point of the bump: a Pro fixture no longer needs a state key of its own, so the twelve specs built on `2friends` can have a Pro Alice without a `2friendsOneWithProAccess` existing. `BuildStateOptions` is now forwarded from both state builders - `openAppsWithState` for mobile, `openSeededWindows` for desktop - and `open_Alice1`, `open_Alice1_Bob1_friends` and `open_Alice1_with_contacts` take it. `grantProToSeededUsers` mints for whichever users `pro` names, and does it BEFORE `linkDevices`: the restore is the client's first sight of the account, so the gated startup fetch fires as it completes. A grant landing after that answers `never`, and both clients then floor the next attempt at 60s from that attempt - which is what makes a spec need `observeProGrant` at all. Still `makeAccountPro` rather than the seeder's own `fetchProProof`. That one verifies the proof against the key the instance actually signs with, which is worth having and worth a follow-up, but this keeps the guards against granting to the shared moderation account and against a phrase that does not derive the account under test. Argument types now compose from `With*` intersections like `WithPlatform` already did, rather than repeating `testInfo`/`testContext`/`stateOptions` inline in nine signatures. Verified: the seeded-grant spec passes on Android (31.8s), iOS (23.3s) and Desktop. One environment note found on the way. The devnet has a dead snode - 1540 of the 15 it registers, every other one up - and the seeder picks a random snode on the account's swarm with no failover, so seeding fails outright when a new account's swarm happens to include it. Unrelated to this change: an untouched desktop seeded spec fails the same way. Worth either reviving that snode or teaching the seeder to try another.
`pressWithCopy` reinvented `clickOnWithText`, and the hand-rolled `waitForElement` on `modal-description` reinvented `checkModalStrings` - which is the better of the two anyway, since it scopes to `[data-modal-id="deleteAccountModal"]` and pins the heading at the same time. A bare `modal-description` wait would be satisfied by any modal carrying that slot. Both were sitting in `clearDataOnWindow` (`linked_device_group.spec.ts`) the whole time. The consequence is the interesting part: `checkModalStrings` reads `innerText`, where a `<br/>` renders as a newline, and then collapses whitespace - landing on exactly what `tStripped` produces for the same token. So the `<br/>`-spanning problem is a MOBILE one, and Desktop can assert whole tokens. `localizedRuns` is gone from this spec; keeping it would have been carrying a workaround for another platform's constraint. Verified against `proClearAllDataDevice`, which spans two breaks. Both cases still pass, and faster: 3.6s and 2.6s against 6.1s and 3.4s. CLAUDE.md records both - reach for the desktop primitives, and the `<br/>` rule is mobile-only.
Adds the standard-account device case back, on all three platforms, now that it exists to test. It could not before on mobile: a standard account pressing Clear with device-only selected had its data deleted on that press - Android's `SettingsViewModel.clearData` fell through to `clearDataDeviceOnly()`, iOS's `clearDeviceOnly()` to `clearLocalAccount()` - so there was no confirmation to read and the test would have wiped the app instead of asserting. The client PRs change that, and this is what stops it regressing to a one-tap wipe: reaching the assertion at all means the confirmation was there. It also closes the gap the previous commit deliberately left open. With Pro cases alone, nothing separated "shows the Pro copy to Pro users" from "shows the Pro copy to everyone". Device branch only. That is the branch the clients changed, and the same `useCurrentUserHasPro()` drives both, so a network control would pin nothing new. No existing spec needed updating: this is the only mobile spec that clears data, and the three desktop ones that touch the flow (`delete_account`, `linked_device_group`, `enforce_localized_str`) sit on behaviour that did not change. Verified: iOS 3/3, Android 3/3, Desktop 3/3.
`localizedRuns` existed because a copy spanning a `<br/>` can never match a locator's text filter: `tStripped` puts a single space where the break was, and the rendered value has a newline (mobile) or no character at all (a DOM `textContent`). Splitting the token into runs was one way round that. Collapsing the whitespace of the value being compared is a better one. `readDialogBody` now does it, and the specs assert the WHOLE `tStripped` token - which pins more than a fragment did, since a fragment could not say the two halves were in the right order or that anything sat between them. Verified on both mobile platforms; Desktop already got this for free from `checkModalStrings`, which reads `innerText` and normalises the same way. So the helper is gone rather than mobile-only, and CLAUDE.md now describes the normalisation instead of pointing at a file that no longer exists. Also completes the grid. Each of the four cells - `deleteMode` crossed with `useCurrentUserHasPro()` - carries its own token, so the standard-account NETWORK case is the only thing asserting the copy that warns a standard user their messages cannot be restored. `delete_account.spec.ts` walks that branch but never reads the confirmation. Previously left out on the grounds that it "pins nothing new", which was wrong. Verified: iOS 4/4, Android 4/4, Desktop 4/4.
Two things, both replacing hand-rolled work with harness machinery that already existed. `readDialogBody` reimplemented the whitespace normalisation the matchers already do. `findMatchingTextInElementArray` and `findMatchingLabelInElementArray` both collapse whitespace before comparing, which is exactly what makes copy spanning a `<br/>` comparable to the single space `tStripped` leaves in its place. So the locator's own `text`/`label` filter does the whole job, and `withCopy` picks the right one per platform - `label` on iOS, where an identifier displaces the display text; `text` on Android, where a text-bearing node still carries its own. That match is EXACT rather than a substring, which is strictly stronger and removes the separate "and not the Pro copy" assertions: a body that equals `clearDeviceDescription` cannot also contain the transfer warning. `withCopy` sits beside `expectControlCopy`, and the pair is the real distinction - a text-bearing node can be checked on both platforms, a Compose control only on iOS. Separately, `cancelClearData` was passing `PRESENT_MAX_WAIT` to `verifyElementNotPresent`, which sleeps its `maxWait` UNCONDITIONALLY before looking. That was a flat ten seconds per case rather than a bound a fast dismiss escapes. On ABSENT_MAX_WAIT the Android cases drop from ~29s to ~20s. Verified against builds carrying the client behaviour change, not dev: iOS 4/4, Android 4/4.
# Conflicts: # run/test/state_builder/index.ts
An account already holding more pins than the limit gets `proCallToActionPinnedConversations` ("Want
more pins?"), not `proCallToActionPinnedConversationsMoreThan` ("Want more than 5 pins?"). The clients
branch on it deliberately - Android in `ProComponents.kt`, crossed with whether the plan has expired -
and telling someone holding six pins "want more than 5" would read as nonsense.
The seeded over-limit spec asserted the at-the-limit copy, so it failed on Android against correct
behaviour. Adds `pinnedConversationsOverLimit` to the CTA table rather than loosening the existing
entry: the two are different states, and the spec's second CTA check is at FIVE pins, where the
original copy is still right.
This was referenced Aug 27, 2026
Bilb
commented
Aug 27, 2026
Bilb
commented
Aug 27, 2026
Bilb
commented
Aug 27, 2026
…-seeder The spec attempted its extra pin on `contactNames[6]` - the first UNPINNED row, which with six seeded pins is already sitting in position 7. Pinning it moves it to the end of the pinned block, which is also position 7, so the conversation order comes out identical whether the client honoured the pin or refused it. `assertPinOrder` could not tell the two apart: the step read as a guard and asserted nothing. Pinning the LAST row instead moves it from tenth to seventh when allowed, so the order says which happened. That matters most on iOS, where the CTA check cannot stand in - see below. With that fixed the spec immediately found what it was written for: **iOS allows the seventh pin**, leaving a standard account holding seven against a limit of five. Kevin moves from tenth to seventh. Android refuses it and raises the CTA. Both symptoms have one cause, so the CTA assertion stays on both platforms rather than being gated to Android - gating it would hide a real defect behind a green run. Also bumps qa-seeder 0.4.1 -> 0.6.0, which adds `grantProPayment` (payment on record, with `durationSeconds`, the shared-admin refusal and a granted-duration check) and `sendProMessage`. Both are what `pro_over_pro_limit_message` has been waiting on. No breaking changes for existing callers.
The marker had no identifier on iOS, so `ConversationPinnedIcon` threw for it and every pin-icon assertion was gated behind `.onAndroid()`. session-ios #767 tags it, carrying the conversation name on the label the way the cell already does, so all four call sites now run on both platforms. That immediately caught two things. The pin marker is what proves an over-limit config renders as it is rather than being clamped to five — before this, the iOS half of `pro_pin_over_limit_from_config` rested entirely on the conversation order, which cannot tell a hidden marker from an absent one. And `findElementQuietly` ignored a locator's `label`, so `waitForElementToBeGone` matched any element sharing the id. Unpinning one of six conversations left five markers behind and the wait could never pass. The "gone" path now narrows the same way the "present" path does. Also drops the note claiming iOS pins the seventh conversation. It does, but only without session-ios #762 plus #767 — the comment now says which build that is rather than reading as settled product behaviour.
Every existing character-limit spec is written from the sender's side, because that is the only side a client can reach: the composer refuses past the limit. So the receiving half of the rule was untested — accept it whole, cut it at the Pro limit, fall back to the standard limit, or drop it were all equally consistent with a green suite. qa-seeder 0.6.0's `sendProMessage` manufactures the message, consulting no limit and carrying a real backend-signed proof. The sender has to be genuinely Pro: a recipient verifies the proof before honouring anything past the standard limit, so a mocked one would be measuring the wrong refusal. Length is pinned by four probes rather than read, because neither platform exposes it — `boundary` ends at exactly PRO_MAX_CHARS and `overflow` is that plus the one character past it, so only a body of exactly PRO_MAX_CHARS satisfies both. `early` and `late` straddle the standard limit, separating "never arrived" and "proof not honoured" from "cut in the wrong place". Each outcome throws its own message. Bob gets no device or window: he is an account the seeder acts as, so `open_Alice1_Bob0_friends` and `test_Alice_1W_Bob_0W_friends` seed the friendship and open one side. Verified on all three: iOS 19s, Android 22s, Desktop 8s. Desktop's assertions were mutation-checked against a marker that cannot exist, since a fast pass with no output is indistinguishable from a vacuous one. Plain ASCII deliberately. Android and Desktop truncate by code point, iOS by UTF-16 unit, so a non-BMP body lands at 10,000 on two clients and 5,000 on the third. That divergence wants its own spec and a per-platform expectation.
Proofs live a real ~30 days, so nothing lapses on its own inside a run and a spec written to wait for one waits forever. A short-lived entitlement now has to be asked for, via the seeder's `durationSeconds`. Observed as ~2.53M seconds remaining on a freshly minted proof, against the ~290s the runbook promised.
session-ios #767 moved the conversation name from the marker's accessibility label to its identifier, on review: a label is read aloud, and the screen reader has just announced the same name from the cell. The marker is no longer an accessibility element at all, so it is now invisible to VoiceOver rather than merely less repetitive. The selector is therefore per-conversation, which also means the `label` filter is no longer what narrows it. The `findElementQuietly` fix in the previous commit stays: `waitForElementToBeGone` ignoring a locator's `label` is a real asymmetry with the "present" path, it just is not this locator's problem any more.
Bilb
commented
Aug 28, 2026
`markedMessage` and `overProLimitMessage` were the same three steps written twice: pad to an offset, drop a marker, repeat, then re-read the result to prove the offsets came out right. Only the markers and the total length differed. `bodyWithMarkers` takes the markers and the length and does the rest. It also proves more than either did: that no marker overlaps the one before it, that they fit inside the body, and that each occurs exactly ONCE — a marker appearing twice makes a probe unable to say where the copy was cut, which neither version checked. The EARLY/LATE pair is shared rather than repeated, since both bodies want it for the same reason and the straddle only holds for tags short enough to leave EARLY inside the standard limit. Verified behaviour-preserving: both functions emit byte-identical bodies to the previous implementation across five tags including a 33-character one, and the three specs that consume them pass. Also fixes two lint errors that were on the branch, one of which I reported as clean off a grep that had matched a filename.
The mobile and desktop suites carried the same partition-and-compare assertion twice, and only the mobile copy documented the trap that makes it toothless: pinning a prefix of `beforeOrder` expects `beforeOrder` back, so the assertion cannot fail. One copy now lives in `run/shared/conversation_order.ts` with that note, following the `assertUrlIsReachable` pattern; both suites re-export it, so no call site moved. The two `getConversationOrder` readers stay per-platform — one takes a Playwright `Page`, the other a `DeviceWrapper`. `verify` moves to `run/shared/verify.ts` alongside it. It depends on nothing but Playwright, so keeping it in the mobile grab-bag meant a desktop import would drag `appium-xcuitest-driver`, `@wdio/types` and the adb helpers into the process for a 30-line wrapper. `assertPinOrder` deliberately stays on plain `expect`: `verify` replaces Playwright's diff with a comma-joined `String()`, which reads well for the scalar comparisons it is used for and loses exactly what an order failure needs — which two names swapped.
The mobile copy used `verify` before the merge, and the only reason the shared one did not was that `verify` lived in the mobile grab-bag. It no longer does, so restore the failure output the mobile suite had — and give the desktop suite the same clean Allure message. The cost is a `.toEqual` over arrays printing both orders comma-joined rather than as a diff. Both lists print in full, so a swap is still readable.
mpretty-cyro
approved these changes
Aug 28, 2026
mpretty-cyro
added a commit
that referenced
this pull request
Aug 28, 2026
#165 landed the iOS pinned-marker locator and a second pinned-conversation CTA, both of which this branch had reached independently. - `ConversationPinnedIcon` for iOS is the same selector on both sides; kept main's comment, and carried over the note that a composite id is matched rather than an id paired with text, which breaks once an id is added. - The CTA table now holds three pinned-conversation entries, not two: at the limit, already over it, and over it having previously subscribed. They are three of the four cells the clients pick from, and none substitutes for another. - In the pin loops, main's removal of the `.onAndroid()` gate stands (the iOS locator exists now) and so does this branch's removal of the explicit `PlusButton` wait, which moved inside `pinConversation`.
mpretty-cyro
added a commit
that referenced
this pull request
Aug 28, 2026
Brings in main through #160: the iOS pinned-marker locator, the third pinned-conversation CTA, and the qa-seeder bump. Nothing here overlaps with the picker work — the only shared file is `DeviceWrapper`, and the two changes sit in different methods.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Supersedes #157, #159 and #164, which are closed in favour of this. #159 depended on #164, and all
three touch the same state-builder plumbing, so they conflict pairwise and are easier to review as one.
What is here
Seeded Pro, on any state (was #164). qa-seeder 0.3.0 → 0.4.1, where
1userWithProAccessis goneand Pro is an option that composes with every state key.
BuildStateOptionsis threaded through bothstate builders, and
grantProToSeededUsersmints for whoeverpronames — beforelinkDevices, sincethe restore is the client's first sight of the account and a grant landing after it answers
never.Seeded pins above the client's limit (was #159). Only a seeded config can produce that state: the
client refuses the sixth pin itself, yet config carrying more arrives in production from a linked
device or a restore.
The Pro transfer warning on Clear All Data (was #157). 12 tests, the full
deleteMode×useCurrentUserHasPro()grid on all three platforms. Every case reads the copy and cancels — thedestructive action is never taken.
Verification
Mobile was run against clients built from the merged
qa/confirm-device-clear-for-allbranches, notdev.The iOS pin spec fails, and it is not the spec's fault
Pinned conversations above the limit arriving from configfails on iOS with no CTA raised — threeconsecutive runs, while the pre-existing
Pinned conversation limit (non Pro) @iospasses in the samesession.
That is the case the spec was written to catch, and its comment predicted it:
Supporting evidence from the iOS source:
pinnedConversationLimithas no enforcement call site. Itsonly uses are the protocol declaration, a default, and interpolating
{limit}into the CTA copy inProCTAModal.swift. Android handles the same state correctly.So this needs a decision before merge: file the iOS bug and narrow that one spec to
androidItuntil it is fixed, or hold the whole PR. I have not narrowed it unilaterally — a spec that fails
because it found something is worth more than a green one, and which way to resolve it is yours.
One fix made while verifying
The over-limit pin CTA uses a different token:
proCallToActionPinnedConversations("Want morepins?"), not
...MoreThan("Want more than 5 pins?"). The clients branch on it deliberately, andtelling someone holding six pins "want more than 5" would read as nonsense. #159 asserted the
at-the-limit copy and so failed on Android against correct behaviour. Added
pinnedConversationsOverLimitto the CTA table rather than loosening the existing entry — the spec'ssecond CTA check is at five pins, where the original copy is still right.
Merge conflicts resolved
#164 and #159 both added
stateOptionsplumbing independently. Unified on #164'sWith*-composedargument types and on a single
stateOptions, dropping #159's separatepinsparameter —stateOptions.pinsalready expresses it, and one mechanism means a future spec can ask for Pro andpins together.