Firmware 1.18 gives contact flag 0x10 a security meaning (the contact may
run any companion CLI command over the mesh, unseen by the app), forwards a
refused remote command as a txt_type 3 DM, and serves the companion CLI over
the app link (CMD_RUN_CLI_COMMAND, fw_ver_code 14).
- Remote CLI permission is visible: red badge on the contact card and in
the DM contact-info window. Console change_flags shows flags in words,
accepts 0x.., reports old/new, warns on grant, restores the in-memory
record when the device refuses, and invalidates the contacts cache.
- txt_type 3 DMs are stored with their type, rendered as "Remote CLI
command - not executed" with the command verbatim, logged at WARNING,
and the notification says so.
- New console command `cli <command>`, gated on fw_ver_code >= 14 and on
the library having run_cli_command (2.3.9+, requirements untouched).
poweroff/shutdown refused; reboot and `set name`/`set path.hash.mode`
routed through the existing commands so caches follow.
- Re-adding a contact the device holds (a rename) starts from the device
record, so its flags and path survive instead of being reset to zero.
- Fix: approving a pending contact raised KeyError('out_path') for every
record not produced by a NEW_CONTACT event (advert-injected entries and
the DB fallback). Records are now completed before add_contact(), and a
device ERROR is reported instead of a false success.
- set_channel refuses a named slot with an all-zero key.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"SNR | Hops: 4 | Route (5): 2 bytes | Region: pl" took two rows on a
phone even without a region, and the words carried nothing the icon and
its tooltip don't. Hops, route and region now show as an antenna,
a signpost and a map pin, each with the word it replaces as its title;
the SNR keeps its label, being short already. The icons match the rest of
the UI: bi-signpost-split is the project's path icon and bi-pin-map the
status bar's region indicator.
The hash size joins them as "2B" — the SI/IEC symbol, upper case for byte
— with the spelled-out "2 bytes" kept for the tooltip. Each fact is also
nowrap, so "(5) 2B" can no longer break across rows mid-value.
On a 390 px phone nothing wraps any more: 0 of 500 Public messages and 0
of 11 on a channel carrying regions, where before every region message
took two rows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The line under an incoming channel message showed the SNR and hop count of
the one copy the firmware decoded, while the popup listed only the copies
relayed by repeaters. With the sender in direct range that read as a
contradiction ("Hops: 0" above, "Hops: 1" on every popup entry), because
the direct copy has an empty path and was filtered out of the echo arrays.
The line is now just "Route (n): <hash size> | Region: <name>", and the
popup carries everything it used to: the echo arrays include every copy
heard, in the order heard (ties on the 1 s received_at broken by id), a
direct copy is listed as "Direct" with 0 hops and no copy/analyzer action,
and the copy the node actually decoded is marked "received" — matched on
hop count and SNR against the message row, so a copy missing from the RX
log never mis-marks the next one.
The hash size is coloured red / orange / green for 1 / 2 / 3 bytes,
semi-bold, with per-theme tokens kept at or above 5:1 on the bubble; the
whole line goes from 0.7 to 0.8 rem and --text-meta from ~2:1 to 4.5:1,
since it was hard to read on a big screen. The popup now opens to the
right of its tap target: with the route first in the line, a
right-anchored popup ran under the channel sidebar on a desktop.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A channel used to have two settings: every message or nothing. Now it has
three - muted, every message, or a named profile of rules (mentions me,
message contains, sender contains; any or all must match). A channel on a
profile counts only matching messages: the unread badge, the navbar bell,
the app-icon badge and the browser/Android notification all follow the same
rule. Direct messages are untouched.
- app/notify_profiles.py: rule matching (case- and diacritic-insensitive,
same table as the chat filter), validation, storage in app_settings
- read_status.notify_profile column, written together with is_muted so a
mute never leaves a profile behind; migration added
- /api/notification-profiles CRUD, PUT /api/channels/<i>/notifications;
/api/read_status and /api/messages/updates report channel_notify_profiles,
the latter counts a profiled channel's unread through the profile
- Manage Channels: the bell opens a menu (Off / Every message / profiles /
Manage profiles...) instead of toggling; profiled rows get a green badge
- Settings > Notifications: profile list with in-use counts and an editor
- app.js evaluates the same rules for socket-delivered messages, and a
state version guard stops an in-flight poll from undoing a fresh
mute/favorite/profile change
- i18n en/pl (43 keys), docs (user guide, architecture, whatsnew), tests
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
console.html and logs.html are always-dark terminal pages that
deliberately do not load theme.css, so the var(--text-secondary) the
notice asked for resolved to nothing there and the text fell back to
Bootstrap's near-black body colour — roughly 1:1 against the navy
background, i.e. invisible.
Fixed twice over. Every colour in the demo CSS now carries a fallback,
so a page without theme.css degrades to a readable light notice instead
of to nothing — that closes the whole class of bug rather than this one
instance. And console.html restyles the notice in its own palette,
alongside the other hardcoded colours it already owns.
Measured contrast: console 12.22:1, light theme 7.76:1, dark theme
5.71:1 (WCAG AA for small text is 4.5:1).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mc-webui has no login, which is fine on a LAN and not fine once the
address is handed to testers: they assume the node is theirs and rename
it, and the console is right there for anyone curious enough to type
into it.
MC_DEMO=true refuses everything that reconfigures the radio, destroys
data, reaches other people's repeaters, rebuilds the containers,
publishes to an external broker, or hands out a secret. Reading and
sending messages keep working, so the instance is still worth visiting.
The guard is default-deny: an api_bp.before_request hook refuses every
non-GET unless it is on a short allowlist, so an endpoint added later is
locked without touching this module. That mattered — six mutating
endpoints already had no frontend caller and would never have made a
hand-written blacklist. A small read denylist covers the GETs that leak
(repeater passwords, backup download, capture download, /api/logs),
which the write guard never sees.
Two paths bypass the HTTP layer and are closed separately: the console
is a Socket.IO event, not a route, so it is checked against a read-only
command set; the log viewer is refused at the connect handshake.
Access is kept two ways. MC_DEMO_TRUSTED_NETS unlocks by CIDR, and
MC_DEMO_UNLOCK_CODE unlocks by a code typed once in Settings and held in
an HMAC cookie for 30 days — stateless, and deliberately not Flask's
session, whose SECRET_KEY is a hardcoded public constant.
The trap the network list walks into: published through a tunnel, every
visitor arrives from the tunnel's address, and if the tunnel runs on the
LAN then trusting the LAN unlocks the instance for the whole internet.
An address arriving with a proxy header is therefore not matched unless
MC_TRUST_PROXY is on, and startup logs report the resulting shape.
Frontend is advisory only: demo-lock.js loads from _head_i18n.html so it
reaches all eight entry points including the six iframes, greys out
[data-demo-lock] rather than hiding it (the instance exists to show the
app off), re-applies via MutationObserver for JS-rendered buttons, and
turns any 403 demo_locked into a toast.
Verified on the local container: the rename, reboot, updater, vacuum,
cleanup and the leaky GETs all 403; sending, read markers and ordinary
reads pass; `set name` is refused over the console socket while `infos`
still answers; unlock/lock round-trips. 86 new unit tests, en/pl at 100%.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Opening a channel always landed on the newest message, so catching up on a
busy channel meant scrolling up and hunting for the last familiar message.
Both message lists now draw a divider at the read/unread boundary and open
on it instead of at the bottom.
The boundary is frozen on entry (unreadAnchorTs / dmUnreadAnchorTs) because
displayMessages marks the view read on every render and would otherwise
erase it before it could be drawn. It is recomputed only on a channel or
conversation switch, so resyncs, the geo-cache re-render and the 60s DM
refresh keep the divider in place rather than making it vanish a second
after arriving.
No divider when there is nothing to catch up on, on a first visit
(last_seen 0) or in the archive view. When every loaded message is unread
the divider renders first but the view stays at the bottom — the list
already starts there and scrolling would imply history above it.
Also fixes two scroll-position bugs this exposed:
- DM displayMessages scrolled to the bottom unconditionally, so the
periodic refresh yanked anyone reading history. It now honours a
dmIsUserScrolling flag, matching channel chat.
- Sending resets the flag in both views; without it a user parked at the
divider would send into a part of the list they cannot see.
Verified in Chrome against the local container across light/dark and
desktop/mobile: boundary lands between the anchor and the next message,
divider spans the full width in both flex lists, filter hides it, and an
incoming message leaves the view parked while an own send drops to bottom.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the sharing feature: you can now send what the cards render.
A "+" button next to the emoji button opens a three-item menu — contact,
channel, location. Five actions in the official app collapse into three
because "me" and "my position" are not separate destinations, just the
first, pinned entry of the picker they belong to. Same number of taps,
one less menu level, and every entry behaves identically.
The payload is inserted into the input as plain text, byte-identical to
what the official app sends. A protected "chip" was considered and
dropped: it is unknown whether the official app renders a card when the
token is not the whole message body, and that risk is not worth the
cosmetics. The reassurance went into a live preview above the composer
instead, rendered through the very same processMessageContent() the
recipient will use — so it cannot drift. Edit the token by hand and the
preview vanishes, which is immediate feedback that the message will no
longer render as a card.
Details worth knowing:
- The contact picker lists device memory and cache together, since the
cache is where adverts land and those are the contacts worth passing
on. It renders at most 150 rows: this mesh has over 1500 cached
contacts, and the full list costs thousands of DOM nodes for something
nobody scrolls to the end of. Search runs over the whole set.
- The channel picker carries the region scope along, because a recipient
transmitting under the firmware default would not match the sender.
- The location picker holds both cases in one dialog: our own position as
a button, plus a Leaflet map for any other point.
- Byte budgets are enforced up front (135 channel / 150 DM). A token that
cannot fit is refused rather than truncated — shortening a public key
or a channel secret destroys it.
- The DM composer starts disabled until a conversation is chosen, so the
button mirrors the input's disabled state via a MutationObserver.
Also fixes _selfInfo being populated only inside the map modal's shown
handler, which left it null on a fresh page — the location cards need our
own position at render time for distance and bearing.
Verified in a real browser against the live device: 32 assertions on the
channel composer, 13 on the DM panel (including its local map modal),
both suites green with no console errors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MeshCore clients share contacts, channels and positions as plain text in
ordinary chat messages. Until now those arrived as unreadable raw tokens.
They now render as interactive cards in both the channel chat and DMs.
Three wire formats, none of them ours to choose — they are what the
official app emits, so share-tokens.js stays byte-compatible with it:
contact <64-hex-pubkey:type:name> (compact, not a URI)
channel meshcore://channel/add?name=&secret=®ion_scope=
location bare "lat,lon"
The contact URI form is parsed too (QR codes carry it) but never emitted:
with a name like "MarWoj 💡📱 mobile" it costs 153 bytes and cannot fit a
135-byte channel packet, where the compact token needs 91.
Parsing runs BEFORE HTML escaping, leaving private-use-area placeholders
that the later passes cannot match. Tokens do not survive the existing
pipeline otherwise: escaping breaks a URI's '&', processChannelLinks eats
a '#channel' name, processMentions an '@' in a contact name, and the
token's closing '>' collides with quote syntax.
Cards resolve their action before rendering, so the button states what
will actually happen rather than surprising the user afterwards:
contact Add / Update name to X / Already in contacts / Push to device
channel Add / Already joined / Name used by a different key
The public key is the identity — a known key under a new name is a
rename, never a second contact.
Shared contacts land in the local cache rather than device memory:
they arrive unsolicited and contact slots are limited, so promotion stays
deliberate. The card offers it inline once cached, so it costs one click
rather than a trip to the Contacts panel. New: manual-add accepts
target=cache, which needs no device connection.
Location cards add distance and bearing from our own advertised position,
plus map/copy/external-map actions. A shared channel's region scope is
carried over on join, since transmitting under the firmware default would
not match the sender.
Coordinate detection is deliberately narrow — both numbers need 3+
decimals, ranges are checked, and matches inside URLs are skipped — so
prose like "1,5 osoby" or "wersja 2,10" and Google Maps links are left
alone.
Verified against real payloads received from another device on #mcwebui,
covering all three formats plus a contact URI and emoji names: 65 parser
assertions, 57 render assertions, i18n check clean at pl 100%, and both
themes screenshotted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
iOS WebKit zooms the whole page when a focused form control computes to
under 16px. `#messageInput` was 0.9rem (14.4px) on narrow screens, so
every tap on the message box tripped it - reported on iPhone in both
Safari and Chrome, on a phone with no configuration of its own.
The zoom also never undid itself: the shell is `height: 100dvh;
overflow: hidden`, so WebKit has nothing to scroll back and leaves the
page scaled until the user pinches out.
Raise every focusable control to 16px on (pointer: coarse), which also
covers landscape phones wider than the 768px breakpoint. Desktop keeps
Bootstrap's smaller sizing. The guard uses !important on purpose: it has
to outrank page-local <style> blocks that load after style.css and reach
specificities like `.filter-sort-toolbar .form-select`.
Fields that only open a picker (checkbox, radio, range, color, file,
buttons) are excluded - they never trigger the zoom.
The contacts toolbar was the one layout that could not take the bump:
three 16px selects overflow one unwrapped flex row, clipping the sort
control, so it now wraps.
Verified locally at 390x844 and 932x430 with touch emulation: no control
under 16px on any route, no horizontal overflow, desktop unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tapping the navbar channel selector focused a text input, so Android
raised the on-screen keyboard on every channel switch — half the screen
covered for a list that is four or five items long. The type-to-filter it
enabled solved a problem nobody has: the firmware caps how many channels
a device holds, so there are never enough to search.
#channelSelectorInput becomes #channelSelectorButton, a <button> carrying
the same .form-select chrome, and click toggles the dropdown. The list
itself is unchanged (previews, unread badges, favourites, muted styling),
so renderChannelDropdownItems() just loses its query parameter and the
channels.dropdown.no_matches key goes with it. Keyboard access is kept:
Enter/Space opens, arrows move, Enter selects, Escape closes — the
keydown handler now bails out while the dropdown is closed so Enter
performs a plain button activation instead of picking the first row.
Verified locally at 360px: focus lands on the button (never an editable
element), open/toggle/select/outside-click/Escape all behave, 0 console
errors, and the control renders identically in light and dark themes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Groundwork for translating the interface. No strings are extracted yet — this
stage only adds the mechanism, so the UI is byte-identical in English.
Translations are one flat JSON file per language. An admin can add a language by
dropping <lang>.json into $MC_CONFIG_DIR/translations/ and picking it in
Settings > Appearance — no rebuild, no restart, no compile step. A drop-in file
overrides a built-in one of the same name. Built-ins: en (source) and pl.
How it works:
- app/i18n.py loads and merges catalogs over en.json, so per-key English
fallback is baked in server-side and the JS runtime needs no fallback logic.
Catalogs are fingerprinted with stat() per render, so a dropped-in file is
live on the next refresh rather than the next restart — which matters,
because restarting drops the device connection for up to 60s.
- Template text renders server-side via t()/t_html()/tn() from inject_globals(),
so the first paint is already correct. That context processor covers every
render_template() in the app, including the six standalone iframe pages, so
routes/views.py needed no changes.
- JS text comes from /i18n/<lang>.<hash8>.js, a blocking immutable script shared
by all 8 entry points. The hash is in the path, not a query string, so
intermediary caches and the service worker bust reliably.
- Language = per-browser mc_lang cookie over a server-wide DB default. The
cookie is what makes iframes work: they are same-origin, so they send it
automatically and the existing modal-open reload wiring needs no changes.
t() deliberately does not escape — Jinja autoescape handles that, and escaping
here would double-escape every French apostrophe. t_html()/tHtml() escape their
params but trust catalog markup.
Also fixed along the way:
- save_ui_settings() replaced the whole settings blob instead of merging, so a
language-only POST would have wiped the toast settings. It worked before only
because the one form always submitted every key.
- `const t = document.getElementById(...)` in populateUiSettingsForm shadowed
the global translation helper; same for `var t` in six theme IIFEs.
scripts/i18n_check.py now fails the build if that pattern comes back.
- Removed app/templates/contacts.html, dead since the contacts pages were split
(no render_template reference anywhere), and two stale CSS comments.
Verified locally: catalog route headers/security, drop-in without restart,
malformed catalogs logged and skipped rather than offered in the picker,
cookie propagation into iframes, and 360px layout. Python and JS agree on
interpolation, escaping and Polish plural categories.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dropdown already rendered a .sidebar-unread-badge element, but the
styling rule was scoped to .channel-sidebar-item, so on narrow screens the
count fell back to plain text in the row's own colour. Extend the selector
to .channel-selector-item so both views share one badge style.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Clicking a route in the chat path popup now opens Path Analyzer on the
Map view with that message selected and that exact echo path drawn,
instead of copying the route to the clipboard. Copy stays available as
a small per-route clipboard icon in the popup.
Deep link flow: popup click stores {packet_hash, path hex} in
window.paDeepLink; the modal show handler builds the iframe URL with
?hash=&path=; the analyzer resolves the message after load (widening
the time range once to 7 days if needed), matches the echo by raw path
hex (fallback: shortest), and switches to the map. A plain menu open
still loads the analyzer without any deep link.
Verified live via Playwright: clicked the 3rd (non-shortest) route of a
multi-route message - the map opened with exactly that echo selected,
including the 3-to-7-day widening retry (message was 4 days old).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Full-screen iframe panel wired per the add-fullscreen-panel checklist:
- /path-analyzer Flask view + standalone template (theme bootstrap before
CSS, local vendor assets, Leaflet included for the stage-5 map view)
- path-analyzer.js: days selector (1/3/5/7), message table (time, channel,
sender, preview, copyable packet hash, hops, echo count), spinner/empty
states, local toast helpers fed from /api/ui/settings
- #pathAnalyzerModal in index.html, iframe src set on show.bs.modal for
fresh data each open; FAB button + purple gradient class
- menu entry in #mainMenu (Tools) + placement radio row in Settings ->
Appearance; registered in ITEM_PLACEMENT_DEFS/DEFAULTS (default: menu)
- style.css: #pathAnalyzerModal added to all three narrow-viewport
fullscreen-override selector lists
Verified live via Playwright at 1280px and 390px, light + dark: modal
fills the viewport exactly, placement radios move the entry both ways,
iframe reloads on every open.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The fullscreen-modal override (@media max-width:576px) that zeroes the
default .modal-dialog 0.5rem margin enumerates modal IDs by hand and
omitted #repeatersModal, so on narrow windows the My Repeaters modal
kept an 8px top/left gap that Contact Management did not. Added
#repeatersModal to the three override selector lists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
.dm-content was missing white-space: pre-wrap, unlike .message-content
in the group chat, so multi-line DMs collapsed to one line on the
sender's own screen even though the recipient saw them correctly.
The threshold above which the channel/DM list shows as a sidebar (vs.
collapsing to a top dropdown) is now user-configurable in
Settings -> Interface -> Layout. Persisted per device in LocalStorage
(key: mc-webui-sidebar-breakpoint, default: 992px, range: 600-2000).
Implementation: replaced hardcoded `@media (min-width: 992px)` with a
`.layout-wide` class on <html>, toggled by JS based on window.innerWidth
vs. the user's breakpoint. An inline script in <head> applies the class
synchronously to prevent layout flash on page load (same pattern as theme).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Lets users choose where each action appears: in the floating Quick
Access (FAB) bar or the slide-out Main Menu. Adds a "Hide Quick Access"
master switch and a per-item placement table in Settings -> Appearance.
Removes the stale "Refresh Messages" menu item (legacy of polling era,
WebSocket already covers it) and moves the Notifications toggle from
the menu to a dedicated Settings -> Notifications tab.
Each of the 11 configurable items is rendered in both locations;
applyItemPlacements() toggles d-none based on localStorage. Badges for
DM unread and pending contacts propagate to the Main Menu copies so
they stay in sync regardless of placement.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Each channel item in the desktop sidebar and the mobile dropdown now
surfaces the timestamp of the last message (HH:MM today / DD.MM older)
and a truncated plain-text preview (up to 60 chars, mentions stripped).
Sidebar clamps preview to 2 lines, dropdown to 1 line. Empty channels
render as a single-line name, unchanged.
- api.py: /api/messages/updates returns last_message_preview +
last_message_time; new _make_preview helper strips @[name] syntax
and truncates with ellipsis.
- app.js: new channelLastMessages state populated by the poll loop and
by the new_message socket event; populateChannelSidebar,
renderChannelDropdownItems, and updateChannelSidebarBadges build and
maintain the two-row layout (.channel-item-top + .channel-item-preview).
- style.css: sidebar and dropdown items switch to column flex; new
.channel-item-top, .channel-last-time, .channel-item-preview rules.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
At 1.25rem the brand was still ~120px wide and got truncated to
'mc-we...' on a 360px-wide S20. Drop to 1rem so the whole name
fits without ellipsis while the navbar stays on one row.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The actual root cause: Bootstrap's .navbar defaults to flex-wrap: wrap,
which lets the brand and the controls drop to separate rows when the
total just barely overflows. Adding flex-wrap: nowrap on .navbar and
its .container-fluid (which inherits flex-wrap) keeps everything on
one row. Brand also gets min-width: 0 + overflow: hidden +
text-overflow: ellipsis so it truncates gracefully if there's
genuinely no room (instead of forcing overflow).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The selector was already narrow enough — the real culprit was the
.navbar-brand.h1 sitting at Bootstrap's default 2.5rem (40px), which
on a Samsung S20 ate ~200px just for 'mc-webui'. Cap brand at
1.25rem on screens ≤ 768px so the bell + selector + menu can sit
beside it on one row.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The responsive @media (<768px) block had #channelSelectorInput pinned
at min-width: 100px !important, which pushed the wrapper's actual width
above the wrapper's own min-width: 80px. Remove the input min-width so
the wrapper's 80px takes effect; DM contact input keeps its 100px.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Reduce wrapper min-width 100px → 80px and tighten the form-select
chevron padding (2rem → 1.5rem right, 0.6rem → 0.5rem left) so the
navbar fits in one row on ~360px-wide phones while keeping the
chevron and text both visible.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the native <select> channel picker (used on narrow screens) with
a custom searchable dropdown matching the DM contact picker UX: type to
filter, arrow/Enter keyboard nav, click-outside to close, per-channel
unread badges, muted styling. Wide-screen sidebar (lg+) is unchanged.
Also align the DM picker dropdown font with the wide-screen DM sidebar
(0.88rem / weight 400) — was inheriting larger/bolder from form-control.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Right-align path popup and cap max-width to viewport to prevent
overflow on narrow screens (same approach as DM route popup fix).
Add tap-to-copy on route entries — copies path in comma-separated
format (e.g. 5E,32,0D,8C) to clipboard with visual feedback.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add a Device tab as the first tab in the Settings modal with two sub-tabs:
- Public Info: device name, coordinates with map picker, advert location sharing
- Radio Settings: frequency, bandwidth, SF, CR, TX power with region presets
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add Settings quick-access button to both main chat and DM views
- Make FAB container draggable via toggle button with position saved to localStorage
- Add button size and spacing sliders in Settings > Appearance tab
- Use CSS custom properties for dynamic FAB sizing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove unnecessary border-top separator above action buttons in message bubbles.
Replace 15s deferred loadMessages() after send with real-time echo updates via
WebSocket — API now returns msg_id so optimistic message gets linked to DB record.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move DM timestamp+status row above the message bubble (consistent with
group messages). Increase delivery/SNR meta font size and adjust color
for better readability in both light and dark themes.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Retry counter now renders as a dm-delivery-meta div above the Resend
button instead of inline next to it, matching the position of the
post-delivery info. Prevents text from crowding the button on short
messages.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Long routes (>4 hops) show truncated with dotted underline; clicking
opens a popup with the full route and hop count, same style as channel
message path popups. Short routes (<=4 hops) display inline as before.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move the attempt counter (e.g. "Attempt 15/24") from next to the status
icon to below the message text, left of the Resend button. Add visible
delivery meta line for delivered/failed messages showing attempt count
and path used. Store attempt info for failed messages too. Replace
Polish abbreviations (ŚK, ŚD, ŚG) with English in all log messages.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Show retry progress in DM message bubble via WebSocket:
- "attempt X/Y" counter updates in real-time during retries
- Failed icon (✗) when all retries exhausted
- Delivery info persisted in DB (attempt number, path used)
Backend: emit dm_retry_status/dm_retry_failed socket events,
store delivery_attempt/delivery_path in direct_messages table.
Frontend: socket listeners update status icon and counter,
delivered tooltip shows attempt info and path.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The contact list in Existing/Pending Contacts was not using all available
space due to calc(100vh - ...) and max-height rules overriding the
flexbox layout. Remove fixed height constraints from #pendingList and
#existingList in both contacts_base.html and style.css, letting the
flexbox chain (body > main > container > pageContent > list) fill the
remaining viewport space.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Global .contact-name (1.1rem/600) was bleeding into DM sidebar items.
Added explicit 0.88rem/400 override for .dm-sidebar-item .contact-name.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Create theme.css with CSS custom properties for light/dark themes
- Dark theme inspired by demo landing page (deep navy palette)
- Update style.css: replace ~145 hardcoded colors with CSS variables
- Extract inline styles from index.html, contacts.html, dm.html to style.css
- Add Appearance tab in Settings modal with theme selector
- Bootstrap 5.3 data-bs-theme integration for native dark mode
- Theme persisted in localStorage, applied before CSS loads (no FOUC)
- Console and System Log panels unchanged (already dark themed)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
On screens >= 992px (lg breakpoint), show a persistent sidebar panel:
- Group chat: channel list with unread badges, active highlight, muted state
- DM: conversation/contact list with search, unread dots, type badges
- Desktop contact header with info button replaces mobile selector
- Mobile/narrow screens unchanged (dropdown/top selector still used)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reorganize menu from 2 sections (Network Commands, Configuration) into 4:
- Messages (top, no header) - daily actions
- Network - advert commands
- Tools - Map, Console
- System - Device Info, System Log, Backup, Settings placeholder
Increase navbar button/select touch targets (min 40px) for mobile usability.
Widen offcanvas menu from 280px to 300px.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Redesign DM chat contact selector:
- Replace <select> dropdown with searchable text input + filtered dropdown
- Show only device contacts (from /api/contacts/detailed), not all cached
- Sort contacts alphabetically, conversations by recency
- Type badge (CLI/REP/ROOM/SENS) shown in dropdown items
- Keyboard support: Enter selects first match, Escape closes
Add Contact Info modal (replaces Retry toggle in header):
- Shows contact name, type, public key, last advert, path/route, GPS
- Auto Retry toggle moved into modal footer
- Designed for future extensibility (manual path setting etc.)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Stats: battery fallback used ${bat} on an object — now uses battery_mv
from core stats when dedicated get_bat returns null
- Info: remove old v1 regex JSON parsing, use v2 dict response directly
- Search FAB: change from bright orange to muted teal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Search, backup, stats endpoints used current_app.config.get('DEVICE_MANAGER')
which doesn't exist — replaced with _get_dm()/_get_db() helpers
- /api/device/info used old v1 CLI — replaced with DeviceManager.get_device_info()
returning structured dict instead of string (fixes map own device marker)
- Moved search button from navbar to FAB menu (between filter and DM buttons)
- Bump SW cache to v7
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Own device shown as red star marker on map (from self_info GPS)
- Contact popups now show "Last seen: X min ago" from last_advert
- New formatTimeAgo() utility for relative timestamps
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- New GET /api/messages/search endpoint using existing FTS5 indexes
- Search modal accessible from navbar search icon
- Debounced search (300ms) across all channel and DM messages
- Results show source (channel/DM), sender, timestamp with highlights
- Click result navigates to the relevant channel or DM conversation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- New blocked_names table for blocking bots without known public_key
- get_blocked_contact_names() returns union of pubkey-blocked + name-blocked
- POST /api/contacts/block-name endpoint for name-based blocking
- GET /api/contacts/blocked-names-list for management UI
- Block button always visible in chat (falls back to name-based block)
- Blocked Names section shown in Existing Contacts Blocked filter
- CSS breakpoint for icon-only buttons: 768px → 428px (iPhone-sized)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix pending contacts always showing "CLI" — compute type_label in API
- Remove Copy Key button from pending cards, make key clickable instead
- Responsive contact buttons: icon+text on desktop, icon-only on <=768px
- Add flex-wrap for button rows on small screens
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove nested @media (max-width: 400px) rule that forced btn-group
to flex-direction: column, causing buttons to stack on mobile.
Also remove now-unused .list-group-item small styles (channel keys
no longer shown).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>