* fix: format every timestamp through one authority Message bubbles formatted their own clock time in QML while the conversation list formatted its in the model, so the same kind of value showed up in two shapes at once, "16:44:34" next to "4:45 PM". Both now render the system locale's short time with the seconds field dropped, and the bubble takes its label from a model role instead of formatting. * fix: switch conversations without flashing the previous one Selecting a conversation updated the header, the message model and the roster as three independent streams, and the view renders them through separate replicas, so a switch showed the previous conversation's rows under the new title, then a false "No messages yet", then the real thread. The backend now reports which conversation the models actually hold, and the panes render rows only while that matches the selection: a thread still loading stays blank for a moment, then shows placeholder bubbles, and only a settled model may call a thread empty. The sidebar and the header no longer wait for that round trip at all: a clicked row carries its own name, kind and description, which the view applies straight away and drops once the backend confirms the switch. * fix: tell the user why the composer is closed The composer showed "Chat not connected" whenever it was disabled, so a connected app with no conversation selected reported a connection problem it did not have. It now names the actual cause, keeping the offline wording for offline and asking for a selection otherwise. * fix: keep a message the module refused The composer cleared on submit and the failure path only raised a toast, so a rejected message was gone: the text had already been dropped from the field and from the conversation's draft. The backend now reports the refused message and the thread offers it back in the composer, leaving a composer the user has already typed into alone. * fix: let status messages go quiet The status bar kept the last event it was handed, so "Address ready" or an invite confirmation sat there for the rest of the session and read as the app's current state. A message now clears after a few seconds; the connectivity label and the identity stay, since those are state rather than announcements. * fix: say what a pending member means without the protocol Inviting someone reported "peer joins when the group commits", the explainer named deMLS and its commit timer, and a pending row's tooltip read "waiting for commit". None of that means anything to the person waiting for a friend to show up in a group. The same facts, including the up-to-a-minute wait, are now stated in the user's terms. * fix: count invited members separately in the group details The details dialog rendered its plural source string literally, "1 member(s)", and counted only the committed roster, so a group with an invitation in flight looked like it had lost the invited person. It now reads "3 members, 1 invited", with the backend reporting outstanding invitations alongside the roster size. * fix: mark a conversation someone else started as unseen A conversation arriving from an invitation appeared in the sidebar with nothing to distinguish it, so a group someone added you to slid in unnoticed among the existing rows. An incoming conversation now carries the unread badge until it is opened, the same as an unread message does. * feat: start conversations from one New menu in the sidebar The header carried two side-by-side buttons that truncated their own labels to "New gr...", plus a connectivity dot the status bar already shows. A single "+ New" button now opens a menu with an entry per kind of conversation, and the dot is gone. Sharing your own address stays the full-width action at the foot of the list. * feat: give every copyable value a copy button next to it Copying a member's address meant clicking the row with nothing saying so, and the dialogs put their copy action in the footer, away from the value it acts on. Each copyable value now carries a copy button beside it, keeping the row click and the inline confirmation as they were. The icon ships with the plugin rather than coming from the design system, so it renders on hosts whose design system predates it. * feat: show details for a direct conversation A direct conversation had no details surface at all: the Details button was reserved for groups, so there was nowhere to read or copy the address of the person you were talking to. Details now opens for any conversation, with a dialog for direct ones carrying the peer's address and the conversation id. The address comes from the conversation's roster, which the backend now fetches for direct conversations too, and its row stays hidden until the roster provides one. * feat: copy a message from its context menu Selecting message text worked, but the desktop reflex of right-clicking a message offered nothing, so copying meant dragging over the text first. A right-click now opens a menu whose Copy takes the selection when there is one and the whole message otherwise. The rows share one menu. * fix: make the open conversation and an empty one readable in the list The selected row's tint sat close enough to the hover tint that the open conversation was hard to pick out, and a conversation with no messages left its second line blank, which read as a rendering gap. The selection is now distinctly tinted and an empty conversation says so where its last message would be. * fix: size dialog display areas to what they show The address and description areas were fixed-height boxes taller than their usual contents, so a one-word description sat in an 80px well with a scrollbar down the side of it. The address now takes the height of its content and stops at the height it used to have, scrolling past that. The description, where a content-sized scroll view sheared the label beneath it, is instead a plain text bounded to a few lines. * fix: make the group self-marker legible The "you" tag that marks the current user in a group's member list was set in the badge text size, small enough to be awkward to read beside the member name. Size it like the name. * fix: keep the error toast clear of the composer The transient error toast was bottom-centered in the window overlay, which laid it over the message input and send button, the exact controls a failed action points the user back to. Show it as a top-center banner instead. * refactor: move the components' private helpers into a d object An underscore prefix only labels a function internal while leaving it callable on the component's surface; holding it in a child QtObject actually scopes it away, the shape the design system's own controls use. The component tests switch from the removed helpers to the action buttons' clicked signal, which exercises the same guards through the surface a user reaches. * chore: pin chat_module to the roster and design system this view needs The redesign draws on design-system controls and tokens that reach this flake through chat_module's builder, and the member panel needs a roster that flags an invite the group has not committed yet and that answers for a direct conversation. One rev carries both; release tags predate it. That design system ships its QML as static modules an app links, so no built artifact holds a loose Logos tree for the QML checks to import; they take its source tree at the rev this flake resolves, which also spares that job a build of the standalone app. * feat: keep this account's address on screen Sharing an address took three actions (a button, a dialog, a copy) for a value the app already knows the moment it is online, and the footer printed an installation id nobody can act on instead. The backend now reads get_address() on the online transition and publishes it, and an account card at the foot of the sidebar carries the short identity, the connection state and the address itself with a copy beside it. * feat: draw the chat's own icon set The redesign leans on glyphs the design system does not carry (a group, a person, an info toggle, a send arrow, an add-member, a caret), and the hosts' Qt has no SVG image plugin, so the module can only load rasters. The sources live under tools/icons with a script that renders them, so a glyph can be edited rather than redrawn, and the existing copy icon is re-cut from the same stroke family. * feat: give every identity an avatar A list of hex labels reads as one undifferentiated column, and the eye needs something faster than eight characters to tell one conversation or member from another. Every model now carries the initials and a colour ramp for its row's identity, hashed the same way in one place so an account keeps its colour across the sidebar, the roster and this account's own card, and a group is squared off and glyphed so it reads apart from a person. * refactor: lay the sidebar out as cards The panes met edge to edge with only a hairline between them, so nothing on screen was grouped and the eye had to find the boundaries. The sidebar is now a conversations card over the account card on an inset background, its rows carry the avatar with the time and unread count stacked out of the name's way, and one full-width New chat button naming what each entry starts replaces the header and its menu. The status strip goes with it: the connection state belongs on the account card, and the messages it carried were either failures the toast already reports or narration of what the list itself shows. * refactor: rebuild the thread around who is speaking A thread of identical bubbles under a bare title gave no answer to "who is this and who else is here", and the composer carried a labelled button and a growing row of hints for a field that only ever sends text. The header now names the conversation with its avatar, one elided line of description and the faces of its members, and offers its remaining facts behind one toggle; a message carries its sender's face and name in their own colour where a run of theirs begins, dates ride on a chip over the thread rather than a rule through it, and the composer is one pill with the send action at its end. * refactor: give the right column the roster and the facts on demand A group's roster had a header and a floating button and nothing else, while the conversation's own facts (its full description, what kind it is, its id, the other participant's address) were locked in two modal dialogs that had to be dismissed before the thread could be read again. The roster is now a card with its count in the heading and Add member pinned to the foot, and one details panel above it carries what both dialogs did, opening and closing from the thread header without covering anything. * refactor: copy a member's address from the row's own menu A copy button appeared on every roster row the pointer crossed, so a list whose whole job is to say who is in the group was never at rest. A right-click on a row now offers Copy address, and the row itself is nothing but the member's face, identity and standing, with an invitation that has not committed saying so in words. * test: cover the redesign's new components standalone Every component has to instantiate from its declared properties alone, with mock data and no host context, and the chat vocabulary the redesign added had no such proof: the icon button, the day chip, the facepile and its overflow, the panel heading and its count, a detail row's copy, and the thread header holding one line of description whatever it is given. * refactor: take the design system's monospace token Every hex identifier the chat shows named the platform's generic monospace family itself, behind a local token standing in for one the design system did not have. It has one now, so the stand-in and its upstream TODO go. * fix: hold the side columns to their widths and open a conversation ready to type A layout nested in another layout fills by default, so the sidebar and the right column took the slack meant for the thread: at 1024px the sidebar rendered 415px against a design of 320. Both are pinned to their widths now, the thread carries a floor so a window too narrow for three columns clips the right one rather than the middle, and selecting a conversation puts the caret in its composer. * fix: keep a pending member on the roster across chat switches The pending marker was tracked in the backend as a single set of addresses for whichever conversation was open, so switching away had to clear it or its rows would leak into the next group. Coming back re-read the roster from the module, which listed committed members only, and the invite vanished from the panel until the group committed it minutes later. The module's roster now flags an invite it has not committed yet, so the marker is read per conversation from the module instead of being tracked here, and survives any number of switches. memberCount stays committed-only, and a pending invite no longer counts as being on the roster when a message arrives from an address the panel has not seen. The same rev answers the roster call for a direct conversation, where it used to error, so the details panel resolves the peer's address instead of leaving the row blank. * docs: bring the README and the exchange screenshots up to the redesigned UI The screenshots showed the pre-card layout, the README claimed a group opened without a dialog and listed roles two models no longer carry, and a hero image nothing referenced sat in docs/ showing a UI that no longer exists. The captures are regenerated from the current app, the README's structure and component tables name the roster model and the identity and time-formatting helpers alongside the two list models, and the walkthrough records why the icons are missing from an offscreen capture. * ci: run the macOS doc-test without the Logos cache The delivery-module .lgx cached in Attic was packaged without the libiconv its librln.dylib loads, so a macOS run that substitutes it instead of building it crashes the doc-test. Without the cache the module builds from source, which realises the dependency. Revert this once the repinned delivery module bundles what its dylibs load.
4.2 KiB
Two-instance message exchange
The chat UI tutorial drives a single chat UI
window: it connects to the network and shares its address. A message
exchange, though, is inherently two-party, and the doc-test ui_test harness
cannot express it on its own: it drives one app instance and can only set
literal values on fields, so it can never read one window's address out and
paste it into another. The
automated exchange tutorial runs that
round-trip headless via the exchange app and captures the result in one
screenshot.
This page covers the other half end-to-end: two real chat UI windows exchanging
encrypted messages over the live network. It is driven by
doctests/exchange/run-exchange.mjs,
which speaks the logos-qt-mcp
inspector protocol to both instances at once, so it can read Alice's address
out of her window and hand it to Bob, then capture each side of the
conversation. The screenshots below are produced by that script.
Two instances coexist on one host because each picks a random QtRO socket name
and each delivery node picks its own listening ports; the script gives each its
own session dir (--user-dir) and QML inspector port (QML_INSPECTOR_PORT).
Run two instances interactively
To exchange a message by hand, launch two standalone apps; a distinct session dir keeps the two module instances cleanly apart:
# window A
nix run . -- --user-dir ~/.local/share/chat_a
# window B
nix run . -- --user-dir ~/.local/share/chat_b
Both nodes join the logos.test Waku fleet (and publish their key packages to
the key-package registry during init), so this needs internet and ~5-20s to
reach Online. Then, in window A copy the address off the account card; in window B
pick New chat > Direct message and paste A's address — the conversation opens on B's
side and the invite goes out. Once it appears in A's list (she has joined),
send the first message from B; reply from either side.
Regenerating the screenshots
doctests/exchange/run-exchange.sh docs/images/exchange
The wrapper builds the standalone app from the flake, launches Alice and Bob offscreen, runs the exchange, writes the PNGs below, and tears both instances down. It exits non-zero if the round-trip does not complete, so it doubles as an end-to-end integration check.
The icons are absent from these captures: LogosIcon tints its asset through a
MultiEffect, and the offscreen platform plugin has no GPU to rasterize a
shader effect with, so the New chat button, the copy buttons and the send arrow
render as their bare backgrounds. The assets themselves load; a windowed run
shows them.
The exchange
1. Alice shares her address
Alice waits for the network to come up (Online), at which point the backend
has called get_address() and her account card carries her address. Her key
package was published to the registry during init, so the address alone lets a
peer open a conversation with her.
2. Bob opens a conversation and sends the first message
Bob pastes Alice's address into New chat > Direct message. The backend calls
create_conversation(address), which fetches her key package from the registry
and sends her the cryptographic invite; the new thread opens empty. Once Alice
has joined (the conversation shows up in her list), Bob sends the first
message. Bob's window shows the message sent.
3. Alice receives it
Bob's message lands, the message_received event fires, and the thread of the
conversation she joined shows it.
4. Alice replies
Alice replies with send_message(convo_id, content); the reply travels back
over the same conversation.
5. Bob receives the reply
The same path in reverse: Bob's thread now shows both messages. A real bidirectional, end-to-end-encrypted round-trip between two independent UI instances.




