Lands the #523 indicator controls, separated from the factory reset (#1057)
that happened to share their branch. #1057 is destructive, untested and the
owner did not ask for it, so it stays behind rather than riding along.
Rotation is 0xC5 0x1A GET / 0x1B SET, gated on TAG_DISPLAY_ROTATION in the
0x1C advert, with no caps-bit fallback and no probing. It is NOT 0x18/0x19:
those are per-channel channel notify, and an earlier build that put rotation
there corrupted silently, because the firmware's 0x19 handler validates a
channel-notify mode and nothing else. The hazard commit 89f1768e is not in
this history at all; this is the post-fix state only.
Display mode and the status LED read device-info bytes 86 and 85. Nothing
reads byte 87, which contract section 2 retired permanently.
Provenance, since the route matters: cherry-picked ea09d2f3 with -m 1. That
commit is the merge of the #523 work into the #1095 advert work, and the
rotation fix lives in its merge resolution rather than in a commit of its own,
so its first-parent diff is exactly what #523 adds on top of what dev already
carries. The result is 956 insertions across 28 files, matching ea09d2f3's own
stat, and offband_indicators.dart, indicators_view.dart and
indicators_connector_test.dart are byte-identical to the old branch tip.
Adversarial review (gemini-2.5-pro) found a crash, fixed here: the rotation
control force-unwrapped displayRotation, but the advert tag that ENABLES the
control arrives before the rotation GET reply that supplies the value, so the
pane threw on any slow link. It now takes an empty selection, matching the
display-mode control beside it, which was already null-safe. Proved by probe:
restoring the unwrap yields "Null check operator used on a null value" and
turns 2 of the new widget tests red.
The review's second finding, a read reply overwriting a newer confirmed write,
is real but deliberately NOT fixed here. The obvious guard is to drop read
replies while a write is outstanding, and _sendIndicatorSet sets that flag
with no timeout and no cleanup, unlike _awaitDeviceUi which clears it in a
finally. That guard would wedge rotation reads for the rest of the session
after any SET that went unanswered, which is worse than the stale value it
prevents. Giving _sendIndicatorSet the same timeout discipline is the real
fix and is follow-up work, not something to change minutes before a cut.
flutter analyze clean, 1945 tests pass and 1 skipped, including 25 indicators
connector tests and 4 new widget tests.
Not hardware-tested.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings feat/1014-ios-app-review up to date with dev for PR #1049.
Conflicts: the app name in TESTFLIGHT_GUIDE.md and tools/ios-signing/README.md
keeps "Offband MeshCore" (#1050); channels_screen.dart takes dev's channel
menu (new radio sound tile) and re-applies the #1075 scroll wrapper.
testflight.yml is dispatch-only again, as the push trigger was only for the
owner's TestFlight pass (#1047). REVIEW-NOTES encryption line and PLAN
finding 14 updated to match what App Store Connect shows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Epics B3 and C of #544, so there is something testable on hardware.
B3 reads the radio's mode for every reported slot, hooked to
_cleanupChannelSync(completed: true). Deliberately only on completed: the
failure branch restores a CACHED channel list, and addressing a slot from a
cache is the guessed-slot error this must not make. When #1061 lands its
stricter confirmed-list barrier this moves onto it.
C adds 'Radio sound' as its OWN control beside the phone's notifications,
on the Channels sheet and the channel chat menu, gated on caps2 0x80. The
existing control is relabelled 'Phone notifications' so neither is mistaken
for the other. Neither reads the other: that independence is pinned by the
A2 tests.
C3, the owner's call: the row shows what the channel will ACTUALLY do, not
the stored byte. A set mode decides for itself; an unset one reads 'Follows
the radio: <scope>'; and a device scope of None shows as silent even on a
channel set to All, because telling a user with a muted radio that a
channel is on 'All messages' is the confusion this feature exists to end.
Null composes to a reading state, never to an invented mode.
Proved that check can fail: removing the None-override branch fails the
test that asserts it. Restored.
Strings are English-only with untranslated.json regenerated, per the l10n
rule.
flutter analyze clean; full suite 1809 passed.
#1075: the contact, channel, message and channel-sender menus were plain
Columns in a sheet capped at 9/16 of the screen, so items past the cap were
clipped. A room server's Delete Contact was unreachable on an iPhone. They
now scroll, and the sheet can grow to the safe area.
#1074: a message with an @mention or contact card was built as rich text
whose plain segments were never linkified. Those segments now go through
LinkifySpan with the same options, style and confirm dialog as Linkify.
Three new widget tests fail without the fix and pass with it.
Epic #1078.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
iOS has no system Back. The chat had a hamburger in place of the back arrow, PopScope(canPop: false) switched off the edge swipe, and the keyboard covered the bottom bar with nothing to dismiss it.
Add a back arrow to the channel and the line-of-sight map, keep the edge swipe on pushed screens on iOS, and dismiss the keyboard by tapping the channel's messages or dragging either chat's list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Task 750.4 of the contact-naming plan: the UI. Rename sits on the per-contact sheet, above the destructive Block tile, and the sheet shows what the node calls itself whenever a custom name is set.
The dialog shows the node's own name at the moment of the decision, and only while it differs from what is typed, so the user can see the name they are overriding without having to remember it and the same string never appears twice. It also says plainly that the rename is local and that adverts keep the node's own name up to date, so nobody has to guess what renaming costs them.
Empty and whitespace-only are normalized to a clear in the dialog as well as in the connector and the store. Three layers enforce it independently so none is load-bearing and the dialog never hands a blank downstream to be cleaned up.
A 32-character cap keeps tiles readable. Counted in characters, not bytes: a custom name never crosses the wire, so #636's grapheme truncation does not apply and a byte cap would mean 'shorter for CJK' for no reason.
Thirteen tests. T29 and T30 came from the pre-PR review and I had them wrong first: they accepted an empty string, which IS the defect the review found, so they would have passed against a broken save path. Tightened to assert null strictly, then falsified: reverting the normalization makes both fail where before they passed.
Gates: analyze --fatal-infos --fatal-warnings clean, dart format 0-changed, 1335 tests passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Task 750.1 of the contact-naming plan: model and storage. Stock MeshCore has a per-node CUSTOM NAME field and keeps the original underneath; Offband could not rename a contact at all.
Contact gains receivedName (the name the mesh gave us, wire and storage) and customName (the user's label, client-side only). name becomes a getter returning customName ?? receivedName, so all 85 existing display reads stay correct and a future read cannot forget. Empty and whitespace-only custom names count as unset at both the getter and the store, so clearing the field cannot leave a contact blank with no way back.
The constructor and copyWith take receivedName, which made the rename a compile error at every write site. The compiler enumerated 23 of them across 5 lib files and 11 test files; none was found by reading. The advert handler is the one that matters: receivedName now updates while customName is untouched, so an advert improves accuracy without moving what the user sees. That was the defect making a client-held name impossible.
customName is deliberately NOT in contact_store. That record is rebuilt from radio contact-sync data where no custom name exists, so a save after a sync would wipe it. It lives in contact_settings_store keyed by pubkey hex, which a sync cannot reach.
Both stores persist receivedName, never the getter. The plan's correction on #750 found that persisting the getter is one-way data loss: the custom name would be read back as the received name and the real one destroyed with no advert able to restore it. The ?? 'Unknown' fallback is kept with a comment, since the rename is exactly the edit that drops a trailing fallback and would crash the load for a legacy row.
19 tests, positive and negative. T31 and T32 were falsified before being trusted: breaking the persist and the fallback made exactly those two fail and nothing else.
Gates: analyze --fatal-infos --fatal-warnings clean, dart format 0-changed, 1305 tests passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The owner asked why there were no UI mock-ups to review. There was no good
answer: this Epic's card was built and installed on both his machines without
him ever seeing the design, and the plan had no step requiring otherwise.
So the plan now has B0: mock-ups first, owner-approved before any of it is
built, as a committed HTML file beside the plan in the format his firmware
initiative already uses. epic-b-storage-mockups.html is that file: eleven
numbered variants with real Material 3 tokens, a 360-wide pane, example values
from his own install, and the behaviour to build as bullets under each.
Owner-approved 2026-10-23.
The confirmation (B1a, variants 8 to 10). It states how many copies, roughly
how much space, how many messages are NOT affected, and that it cannot be
undone, with the count on the action button so the scope is the last thing
read. Cancel, a tap outside and back all decline. Nothing eligible opens no
dialog at all, because a dialog that only says zero is not worth opening.
That needed a preview pass: verify every eligible key and total it without
deleting, so the numbers are real rather than estimated. Verification now runs
twice for a confirmed clear, once to preview and once under the key lock. That
is the cost of saying what will go before it goes. approximateBytes is named
for what it is: the sum of blob lengths, not what VACUUM returns, because page
rounding moves the real figure; the dialog says "about" and the result reports
what actually changed.
Writing the preview found a real bug, and it was about to bite him. The
"skip anything stamped this pass" guard exists so installing an update never
deletes a blob on sight, and it was being applied to the button as well. Every
install that migrated before #962 has an empty __msgrows_migrated_at, so the
first press stamped everything and cleared NOTHING. His install is exactly
that: 47 migrated keys, none stamped. He would have pressed the button on a
70.9 MB database with 16.6 MB of reclaimable blobs and been told "nothing to
clear".
Fixed as eligibleByAge, one static rule both preview and run share, so the
dialog cannot promise what the sweep will not do. An explicit press includes
newly stamped keys; the automatic sweep still refuses them. Three tests on the
rule directly, plus one proving the automatic path refuses while the press
clears.
Label stays "Clear Stale History Now" rather than "Compact", owner's call and
the better argument: the name generalises to clearing stale history for other
reasons later, such as #963's retention limit.
14 widget tests and 34 storage tests. Full suite 1,502 pass, 1 skipped.
analyze clean, format clean.
Task: meshcore-open-gan
Agent Mail: TealGlen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Epic B of #962. The manual trigger for Epic A's sweep, so a user does not wait
30 days. It passes olderThan: Duration.zero, which skips the WAIT and not the
safety: the per-key verification inside reclaimVerifiedHistoryBlob is
unchanged, proved by a test that Duration.zero still refuses a key whose rows
cannot be verified.
Its own widget rather than another card in app_settings_view.dart, which is
already past a thousand lines, and so the tests can drive it without standing
up the whole settings screen. Stateful against the repo's usual
StatelessWidget-plus-Consumer habit, because the running flag and the last
result are local to this card and belong to no service.
The sweep is INJECTED, and that is a finding rather than a convenience. The
first version of the widget test called the real sweep and hung past ten
minutes instead of failing: the sweep uses compute, and an isolate deadlocks
pumpAndSettle inside flutter_test's fake-async zone. A widget test should test
the widget. So the card takes the sweep and the size read as parameters, the
nine widget tests feed outcomes in directly, and the two new tests in
stale_history_sweep_test.dart keep the injected semantic honest under plain
test() where isolates work.
Owner decision 2026-10-22, and the reason this Epic exists: the button REPORTS
keys it refused, it does not only log them. "Cleared 1 old copies, 16.0 MB
returned" and "Kept 1 that could not be verified" are both shown, including
when a refusal is the reason nothing was cleared, which is the most important
case to surface rather than the least. The automatic sweep stays log-only.
Two Gemini findings, both fixed:
- A fast double tap ran two sweeps. The disabled button is not enough on its
own, because setState only SCHEDULES a rebuild, so two taps inside one frame
both see a live onPressed. _clear now returns early on _running, and a test
taps twice in one frame and asserts the sweep ran once. Two concurrent
sweeps would have meant two concurrent VACUUMs.
- A throw after real deletes was reported as a total failure. The honest fix
was in the sweep, not the string: a successful sweep must not become an
error because the closing size read threw, so that read now falls back to
bytesBefore, which understates the reclaimed figure rather than inventing
one. The failure string no longer claims nothing happened either.
Strings are English-only, as everywhere else here: gen-l10n falls back to the
template locale, and untranslated.json records the 9 keys across 17 locales,
which is what that tracked file is for.
29 tests in the two files, full suite 1,483 pass, 1 skipped. analyze clean,
format clean.
Task: meshcore-open-gan
Agent Mail: TealGlen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The picker lists only repeaters with a stored path (the only ones that can answer), with a header note explaining that. Search surfaces pathless matches too, but greyed out and non-selectable with a 'No stored path' label, so the user sees the repeater exists and why it is blocked. Default selection skips pathless repeaters; Discover is disabled for them. Widget test covers hidden-by-default + greyed-on-search.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
D1 hardware finding: the repeater picker was an unsorted, unsearchable dropdown, unusable with many repeater contacts. Replaced with a case-insensitive alpha-sorted, searchable list; discovered regions are alpha-sorted too. Widget test asserts sort order and search filtering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A firmware-gated channel overflow action opens a picker that discovers regions from a chosen repeater (reusing RegionDiscoveryService), lets the user select a region or None, and persists it via connector.setChannelRegionScope (C1 store). The dialog is decoupled (data + callbacks + injected service) for widget testing. l10n added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The banner and the Keep / Use radio dialog now appear only after the
forced resync, so their numbers come from the whole recovery: how many
contacts the app has after checking again, and, when the radio answered
"not found" for any saved contact, how many are no longer on the radio.
That sentence is absent when there are none.
English strings only; other locales fall back, regenerated
untranslated.json lists them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every preset test faked the network, so presets missing from
config-profiles main passed them all and reached the owner as
"Couldn't update presets" on a test build twice. The live test
refreshes from the real URLs and fails on any refresh error; a second
live test proves a missing file comes back as HTTP 404. The picker
test now checks that a good refresh shows no error.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The banner sits in MaterialApp.builder, above the Navigator, where
there is no Overlay. Its close IconButton had a tooltip, and a Tooltip
without an Overlay throws ("No Overlay widget found"). In the owner's
release build that painted the window gray the moment the pointer
reached the button.
The close button drops the tooltip and keeps the accessible "Close"
label on the icon. New tests mount the banner in MaterialApp.builder,
as the app does, and hover the close button; both failed before the
fix with the Overlay error and pass now. The earlier tests mounted it
under home:, which has an Overlay, so they could not catch this.
Epic #668.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A short contact sync is now loud (SAFELANE 6). A banner above the whole
app says the radio reported N contacts but sent M and that the saved
contacts were kept; it stays until dismissed. When the automatic retry
is short too, a modal asks: Keep my contacts (the default, focused, so
Enter keeps) or Use the radio's list, which states how many contacts it
would remove. Owner decisions on #670.
The banner sits in MaterialApp.builder next to the storage banner
(#385), above the Navigator, so the modal is drawn in place rather than
pushed as a route. Strings are English; other locales fall back through
untranslated.json, as the storage banner's do.
Widget tests cover hidden, shown with counts, the no-total wording,
dismiss, the decision's default and both choices; with the Keep default
removed, two fail.
Epic #668.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Radio Settings now takes its presets from RadioPresetService instead of
the hardcoded list, which is removed (radio_settings.dart keeps only the
LoRa enums). The picker, extracted as RadioPresetPicker for reuse on the
remote-node screen, groups presets under non-selectable region headers,
labels each MeshCore or Offband, and shows where the list came from with
a refresh button; a failed refresh stays on screen in the error colour.
Selection is tracked by preset id rather than list index, because a
refresh can change the list under an open form. Upstream presets carry
no TX power, so a preset without one matches any power and applying it
leaves TX power as it is. The form refreshes a stale list when opened.
Epic: #726
Agent: MagentaHawk (session 5a8922d6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three accepted findings, plus a real defect the adversarial test cases
flushed out.
A long name overflowed the chip by 405 pixels. The name comes off a
public radio channel and is attacker-controlled, so anyone could break a
message list by posting a card with a long name. The label is now
bounded and ellipsized, with an explicit width ceiling because a
WidgetSpan hands its child unbounded width.
fromChannelShare no longer scans from the first < to the last >. Two
cards in one message were read as a single span running from the first
key to the last name. That was reachable: the add dialog passes raw
pasted text straight to it. It now takes the first well-formed card via
a regex whose name class cannot cross a closing bracket.
The known-contact check is O(1) against the connector's existing
_knownContactKeys set, through a new isKnownContact. Scanning contacts
ran per chip on every notification, and the public knownContactKeys
getter copies the whole set, so neither belonged in a build method.
Card-versus-mention is now carried on the match instead of inferred from
text[m.start] == '<', which would mis-route the day a third bracketed
pattern is added.
Two findings were rejected on evidence and are justified in the PR body:
a claimed missing null terminator (firmware sets it from the frame
length itself) and a claimed greedy-regex failure (disproven by test).
Epic #610.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The receive half of contact sharing. Until now an incoming
<key:type:name> rendered as raw text while the stock app showed a native
Add Contact button for the identical payload, so sharing worked outbound
only: a stock user could add an Offband user from a card, but not the
reverse. Confirmed by the owner on hardware.
Reuses the proven mention-chip mechanism in TranslatedMessageContent
rather than adding new machinery. Both patterns are now collected and
sorted by position, so a message carrying a mention AND a card renders
both in the right order; they cannot overlap, since a mention is @[...]
and a card is <...>.
Tapping opens the add dialog seeded with the card rather than adding
silently. A contact is an identity, so adding one stays a deliberate act
with the key, name and type visible first.
Two deliberate refusals:
- A card whose key is already a contact renders inert, mirroring stock,
which warned the owner rather than silently re-adding.
- A card matching the shape but failing the parser, such as an
out-of-range type, falls back to plain text. Showing an Add chip there
would promise something the parser will refuse.
Parsing already existed from the #611 Gemini review, so this change is
rendering and dedupe only.
Epic #610 under Feature #609.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One getter decides the composer's byte budget on every platform, and it had
collapsed that budget to zero on three of them before anyone noticed: Windows
(#686), Linux (#717) and Web (#718). Each surfaced as a user reporting "the
keyboard doesn't respond", two months and three platforms apart. Nothing
exercised the getter, so nothing went red.
Extracts the decision into `MeshCoreConnector.resolveFrameBudget`, a pure
function of (transport, isWeb, mtuNow). The getter is now a one-line delegate
and behaves identically. This is what makes the web branch testable at all:
`kIsWeb` is a compile-time constant, so a VM test could never enter it and CI
runs the suite on the VM only.
19 new cases, covering what we now know rather than what we guessed:
- wired transports are never floored, and ignore a bogus MTU
- web is exempt from the floor, and both composers stay usable at MTU 23
- native BLE keeps the #395 write-safety floor: unknown MTU yields 20, never
the full frame, because defaulting an unknown link to the largest size would
authorize an oversized write
- the MTUs actually measured on hardware (Android 247, Windows 176) leave a
usable channel composer
- the original failure stays reproducible: an unreported MTU still gives a
0-byte channel budget and 4 bytes for a DM, the two numbers users reported
Also adds widget tests for the #684 notice, including the disconnect case the
pre-PR review caught, and the severity split between a zero and a small budget.
analyze clean, 981 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Owner feedback from testing: a scan button living only inside the public
key field is awkward placement, because scanning is how most people will
actually add someone and it should not be reachable only from inside a
field they have to open first.
Now in both places, routed to the right thing:
- Contacts overflow menu, Scan contact QR, opens the scanner and hands
the result to the add dialog already populated. Nothing to paste.
- The key field keeps its scan button, which fills in place, for when
the dialog is already open.
Same scanner and same parser either way; only the entry point differs.
The dialog gains an optional initialKeyText, and seeds it through the
same handler as a paste, so a scanned link populates name and type
rather than sitting there as raw text.
Platform availability moves to a shared contactQrScanAvailable getter on
the scanner screen, since it now has two consumers. Both the menu entry
and the field button hide on Windows and Linux, where mobile_scanner has
no support and the render half is the desktop path.
Epic #619.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a calm three-state indicator beside each contact name, per the
owner steer: a green check for advert-verified, a neutral outline check
for key-confirmed, a muted key for key-only. No amber and no hazard
glyph, because nothing is wrong with a key-added contact. The scale
reads as how much we know, never as how risky.
The states are not cosmetic:
- advertVerified: a signed advert arrived, so the node itself asserted
its name, type and position, and the raw packet is stored, which is
also what makes the contact re-shareable.
- keyConfirmed: a message with this contact went through. Direct
messages are encrypted with an ECDH secret derived from the contact
key, and the ACK is computed over the decrypted plaintext
(BaseChatMesh.cpp:442,451), so this is proof the holder of the
matching private key is live. It does NOT prove the person is who the
name claims.
- keyOnly: someone supplied a key and nothing has confirmed it on air.
Costs almost nothing to compute. Contact.isAdvertVerified falls out of
the epoch last_advert_timestamp that #627 already writes, and it clears
itself when a real advert rewrites the field. The advert check runs
first so only an unconfirmed contact pays for a message scan, which
keeps a long contact list cheap.
Also fixes a defect the epoch sentinel introduced: _formatLastSeen ran
the epoch through the relative formatter and claimed a key-added contact
was last seen tens of thousands of days ago. It now reads Not heard yet.
Epic #619.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both halves of the visual exchange, sharing one format and one parser.
Render: showMyContactQrDialog puts this device's own identity on screen
as a QR plus the same link as selectable, copyable text. It refuses to
render when not connected, since an empty key would encode a QR nobody
can add.
Scan: ContactQrScannerScreen validates with Contact.isValidShareUri, the
same check the paste path uses, and pops the raw string. The add dialog
routes a scan through the same handler as a paste, so a QR gets no
separate code path.
The scan affordance is gated to platforms mobile_scanner supports
(Android, iOS, macOS, web). On Windows and Linux the QR half is the
render side, which is the better desktop flow anyway: put your code on
the big screen and let the other person scan it with a phone.
Fixes a real defect found by the new test, not a test artifact:
QrCodeDisplay built its QrImageView through a LayoutBuilder, which
cannot answer intrinsic dimension queries, so any intrinsic-measuring
parent threw. AlertDialog measures its content's max intrinsic height,
so the dialog crashed on open. The QR is now bounded by a tight
SizedBox, which answers the intrinsic itself. This was the widget's
first real call site, so the bug had never been exercised.
Entry point is provisional alongside Add by key; #632 reorganises.
Epic #619.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First user-reachable piece of the identity exchange. A dialog takes a
public key, a name and a contact type, and hands the stub to
addContactByKey.
The key field also accepts a whole meshcore://contact/add link and
absorbs every field from it, because that is what someone actually
pastes, and a QR is only that link rendered visually. Whitespace is
stripped so a key copied across a line break still works.
The stub is built by round-tripping through buildShareUri and
fromShareUri rather than constructing a Contact directly, so manual
entry and a scanned QR cannot drift apart. One code path, one set of
invariants.
An informational note states plainly that the contact is not confirmed
on air and that the name is whatever the user typed until the node
adverts. Deliberately informational rather than a warning: nothing is
wrong with a key-added contact (#630).
Entry point is provisional, sitting in the contacts overflow menu so the
feature is reachable. The proper add-contact surface, split away from the
advert affordance, is #632 under epic #623.
Five widget tests pin what reaches the connector: key, typed name,
chosen type, the flood sentinel, and the epoch lastSeen that keeps the
firmware replay guard from muting the contact. Also covered: pasted-link
prefill, wrapped-key whitespace, an invalid key sending nothing, and the
missing-name fallback.
Epic #619.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up to #457. Swept the em-dash character (U+2014) out of the test
tree (test descriptions and comments) and cleaned 8 em-dashes that
landed in lib comments via the #456 refactor after #457 merged, so the
tree is back to zero. Same rules: replaced with commas/colons/periods,
preserved the lone "no data" glyph placeholders, left non-English ARB
untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AppShell decided top-level vs detail by `selectedIndex != null`, but pushed
detail screens (a channel chat, the LOS map) also set selectedIndex to keep the
bottom bar visible. So Back from inside a channel backgrounded/left the app
instead of popping to the channel list.
Decouple the two concerns: add `isTopLevel` (default true), separate from
`selectedIndex`. Detail screens pass `isTopLevel: false` (keep the bar, but Back
pops). The decision is extracted into a pure `AppShell.backAction`
(drawer -> close; detail with a route below -> pop; else -> background), with an
assert that a detail is actually poppable. Unit tests cover the matrix; widget
tests exercise the real system-Back -> PopScope -> pop wiring.
Follow-up #390 tracks the separate AppBar back-arrow path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the database can't open (e.g. the native sqlite library fails to load),
every read/write silently failed and the app opened to an empty, normal-looking
screen — the user thinks their history was wiped (SAFELANE §6 violation).
Probe the storage layer in main() before any store reads
(BlobStore.verifyReadWrite). On failure, a StorageHealthService one-way latch
records it, and a persistent, non-dismissable banner is shown above the whole
app: history is not lost, storage is unavailable, messages are NOT being saved,
restart after fixing. Cross-platform (probe goes through drift, covers web too).
Tests: service state/latch + banner show/hide widget test. Gemini-reviewed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The autocomplete only completed an unambiguous emoji on Enter; ambiguous
queries (e.g. `:shrug` with multiple matches) sent the literal text instead of
the highlighted glyph, and mentions only selected via Tab. Enter now accepts
the highlighted item (emoji or mention) like Tab, then sends; Tab still selects
without sending, Esc dismisses to send literal text. Removes the now-dead
_emojiQuery field. (#231, emoji + mentions per Ben)
Widget tests: Enter inserts the highlighted emoji (ambiguous `:shr`) and the
highlighted mention (`@Bo` -> @[Bob]).
Part of #231.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A reply prepends `@[Name] `, so a reply-gif renders the gif (prior commit);
this adds the `@Name` reply chip above it so it is clear the gif is a reply.
Extracts TranslatedMessageContent.mentionChip + leadingReplyName (shared by
inline rendering and the reply-gif header); the channel bubble builder renders
the chip above the GifMessage. DMs have no reply feature, so channel-only.
Part of #232.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two shared-root render gaps (message tokens were only matched at fixed
positions):
- TranslatedMessageContent: parse @[Name] anywhere (leading, inline, or
repeated) and render each as a chip; messages without a mention keep full
link support (#233).
- GifHelper.parseGif: strip a leading `@[Name] ` reply prefix before matching
so a reply-with-gif resolves to the gif instead of literal text; arbitrary
text before a gif is still left as text (#232).
Tests: parseGif reply/format cases + widget tests for inline/leading/multiple
mention chips. Full suite 402 green.
Part of #233, #232.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Set Path" dialog parsed, built, and displayed hop prefixes as a fixed
1 byte (substring(0,2)), so on a 2-/3-byte path-hash mesh manual entry produced
wrong-width routing paths and the helper text claimed "1 byte". Make it width-
aware from connector.pathHashByteWidth.
- parsePathPrefixes(text, width, invalid): static + testable; each entry must be
EXACTLY width*2 hex chars (wrong-length / malformed go to invalid, never
silently truncated); width clamped >= 1.
- _updateTextFromContacts / display / maxLength group by the configured width;
the display substring is guarded against short keys.
- show() takes pathHashByteWidth; the chat + path-management callers pass
connector.pathHashByteWidth.
- l10n: reworded the path-entry helper / example / instructions to be width-
agnostic (dropped the "2 hex / 1 byte / first byte" claims). The 17 non-English
locales keep their prior strings, flagged for re-translation (English fixed).
analyze clean; 6 parsePathPrefixes tests (incl. exact-length + clamp). Gemini v1
BLOCKER (unguarded substring) + MAJOR (silent truncation) fixed; v2 MAJORs were
the pre-existing refresh button (out of scope), v2 MINOR (1-byte example) fixed.
Closes#155
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>