diff --git a/.github/workflows/doctests.yml b/.github/workflows/doctests.yml index 7c0ab65..d97c7a4 100644 --- a/.github/workflows/doctests.yml +++ b/.github/workflows/doctests.yml @@ -5,6 +5,7 @@ name: Doc-Tests # drives it headless (offscreen Qt): # - chat-ui build + connect + share-address GUI walkthrough (screenshots) # - chat-ui-exchange runs the real two-party round-trip and captures the result +# - chat-ui-group forms a real three-party group and captures the result # # The dedicated two-instance-exchange.yml job stays the robust gate for the # round-trip and regenerates docs/two-instance-exchange.md's screenshots. @@ -102,14 +103,15 @@ jobs: - name: Run doc-tests shell: bash run: | - # Spec list. The two-party exchange needs live P2P networking between two - # nodes that GitHub's macOS runners can't provide (it is gated on ubuntu - # by two-instance-exchange.yml), so it runs on ubuntu only; macOS keeps the - # single-instance chat-ui spec. Both paths stay listed for the workspace - # tests-and-doctests spec discovery. + # Spec list. The two-party exchange and three-party group specs need live + # P2P networking that GitHub's macOS runners can't provide (each has its + # own dedicated ubuntu gate: two-instance-exchange.yml and group-chat.yml), + # so they run on ubuntu only; macOS keeps the single-instance chat-ui spec. + # Every path stays listed for the workspace tests-and-doctests spec discovery. specs=( doctests/chat-ui.test.yaml doctests/chat-ui-exchange.test.yaml + doctests/chat-ui-group.test.yaml ) if [ "${{ matrix.os }}" = "macos-latest" ]; then specs=(doctests/chat-ui.test.yaml) @@ -145,7 +147,7 @@ jobs: - name: Verify markdown generation run: | - for spec in doctests/chat-ui.test.yaml doctests/chat-ui-exchange.test.yaml; do + for spec in doctests/chat-ui.test.yaml doctests/chat-ui-exchange.test.yaml doctests/chat-ui-group.test.yaml; do out="/tmp/$(basename "$spec" .test.yaml).md" nix run github:logos-co/logos-doctest -- generate \ "$spec" \ diff --git a/.github/workflows/group-chat.yml b/.github/workflows/group-chat.yml new file mode 100644 index 0000000..509e178 --- /dev/null +++ b/.github/workflows/group-chat.yml @@ -0,0 +1,60 @@ +name: Group chat + +# Forms a real three-party group conversation across three logos-chat-ui windows +# over logos-qt-mcp (doctests/group/run-group.sh): Alice creates the group, +# invites Bob and Carol, and once the roster converges sends a message that fans +# out to both. This doubles as an end-to-end GUI integration test — the job fails +# if the group does not form or the message does not arrive — and captures the +# screenshots as an artifact for review. +# +# This job is Linux only: it launches three standalone app instances on one host, +# which the script keeps apart via distinct data dirs, delivery ports, and +# inspector ports. macOS still exercises the group flow through the chat-ui-group +# spec in doctests.yml; this job is the dedicated, race-free gate for it (it runs +# the flow against the checked-out tree rather than a freshly pushed github ref). + +on: + pull_request: + push: + branches: [master, main] + workflow_dispatch: + +concurrency: + group: group-chat-${{ github.ref }} + cancel-in-progress: true + +jobs: + group: + name: group chat (ubuntu-latest) + runs-on: ubuntu-latest + timeout-minutes: 90 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@main + + - name: Setup Cachix + uses: cachix/cachix-action@v15 + with: + name: logos-co + authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' + + # The flow runs against the commit under test (FLAKE defaults to the + # checked-out tree) and needs a real network: the three delivery nodes find + # each other on the configured fleet, and init publishes each instance's key + # package to the key-package registry. + - name: Form the group and capture screenshots + run: | + nix shell nixpkgs#nodejs --command \ + bash doctests/group/run-group.sh "$PWD/docs/images/group" + + - name: Upload group screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: group-chat-screenshots + path: docs/images/group/*.png + if-no-files-found: warn diff --git a/CMakeLists.txt b/CMakeLists.txt index 28f4138..34f2a98 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -17,6 +17,8 @@ logos_module( src/ConversationListModel.cpp src/MessageListModel.h src/MessageListModel.cpp + src/MemberListModel.h + src/MemberListModel.cpp INCLUDE_DIRS src ) diff --git a/README.md b/README.md index f814f0c..deb00fd 100644 --- a/README.md +++ b/README.md @@ -8,21 +8,33 @@ Built with [`logos-module-builder`](https://github.com/logos-co/logos-module-bui ## What It Does -The application provides a two-panel chat interface with a dark, terminal-inspired theme: +The application provides a terminal-inspired chat interface with a conversation +list (left), a message thread (center), and a members panel (right) that appears +only for group conversations: -- **Conversation list** (left panel) — shows active conversations with timestamps and unread indicators -- **Chat panel** (right panel) — displays messages and a text input for the selected conversation +- **Conversation list** (left panel) — active conversations with timestamps and unread indicators; group rows are prefixed with `#` +- **Message thread** (center panel) — messages and a text input for the selected conversation; group messages show a short sender label above incoming bubbles +- **Members panel** (right panel, groups only) — the group's roster, with an add-member field Core functionality: - **Identity** — on startup, initializes a chat identity and displays the user's ID in the status bar - **Addresses** — show your address (the **Show My Address** button) and share it with others to let them start a conversation with you -- **New conversations** — paste another user's address to open a private conversation +- **New conversations** — paste another user's address to open a private (1:1) conversation +- **Group conversations** — start a group with **+ group**, then invite peers by address from the members panel (see below) - **Messaging** — send and receive messages in real-time over the Logos network - **Chat lifecycle** — auto-initializes and starts on launch; status shown in the bottom bar Conversations are **ephemeral** — messages and identity exist only while the app is running. +### Group conversations + +- **+ group** creates a group with you as its only member (no dialog; the group opens immediately). +- Collect peers' addresses (each shares theirs via **Show My Address**), paste one into the members panel's add-member field, and press **+** to invite. +- Membership changes are asynchronous: on devnet the group's steward commits an add only after a ~60s commit-inactivity window, then the welcome is delivered, so a peer joins **minutes** after the invite. The roster refreshes on selection, a message from a new member, the **↻** button, or your own add. +- Any member can add another; the invite routes from whoever proposed it. +- During the brief windows while the group is finalizing a membership change, de-mls rejects sends; these surface as an error toast, so retry after a moment. + ## How to Run ### Standalone (recommended for development) @@ -38,6 +50,38 @@ nix run --override-input chat_module path:../logos-chat-module \ The standalone app starts Logos Core, loads `capability_module` and `chat_module`, then launches the QML UI via an isolated `ui-host` process. +### Running multiple instances on one machine + +To try a real conversation or group locally, run two or more standalone apps +side by side on the same host. Each instance needs its own module data directory +and its own delivery-node port; the UI-to-backend QtRO socket name is randomized +per instance, so nothing else has to be set: + +```bash +# window A +CHAT_MODULE_INSTANCE_PATH=~/.local/share/chat_a CHAT_MODULE_DELIVERY_PORT=60000 nix run +# window B +CHAT_MODULE_INSTANCE_PATH=~/.local/share/chat_b CHAT_MODULE_DELIVERY_PORT=60001 nix run +``` + +Add further windows the same way, giving each a fresh instance path and delivery +port (`chat_c` / `60002`, and so on). + +| Variable | Purpose | +|---|---| +| `CHAT_MODULE_INSTANCE_PATH` | The `chat_module` instance's data directory. Two instances must not share one. Defaults to a directory under the app's data location. | +| `CHAT_MODULE_DELIVERY_PORT` | The local delivery (Waku) node's port, bound by the node, so it must be distinct per instance. Defaults to a compiled-in port. | +| `QML_INSPECTOR_PORT` | Only needed when attaching the [logos-qt-mcp](https://github.com/logos-co/logos-qt-mcp) inspector to drive an instance programmatically (default 3768); give each a distinct one then. Interactive use does not need it. | + +Each node joins the `logos.test` Waku fleet and publishes its key package during +init, so this needs internet and ~5-20s per window to reach **Online**. Then +share one window's address (**Show My Address**) and paste it into another +(**+ new** for a 1:1, or **+ group** then the members panel for a group). For +the full walkthrough with screenshots, and the scripted drivers that automate it +(`doctests/exchange/run-exchange.sh` for a two-party exchange, +`doctests/group/run-group.sh` for a three-party group), see +[Two-instance message exchange](docs/two-instance-exchange.md). + ### In Basecamp Build the `.lgx` package and install it: @@ -125,14 +169,14 @@ view. | Qt6 Core, RemoteObjects, Declarative | UI framework + IPC | | [`logos-module-builder`](https://github.com/logos-co/logos-module-builder) | Build system (mkLogosQmlModule) | | [`logos-chat-module`](https://github.com/logos-co/logos-chat-module) | Chat backend module | -| [`logos-delivery-module`](https://github.com/logos-co/logos-delivery-module) | Transport (Waku) — runtime dependency, pinned at v0.1.2 | +| [`logos-delivery-module`](https://github.com/logos-co/logos-delivery-module) | Transport (Waku) — runtime dependency, pinned at v0.1.3 | ## Related Repositories | Repository | Role | |---|---| | [`logos-chat-module`](https://github.com/logos-co/logos-chat-module) | Chat backend — this UI's required dependency | -| [`logos-delivery-module`](https://github.com/logos-co/logos-delivery-module) | Transport (Waku) — runtime dependency, pinned at v0.1.2 | +| [`logos-delivery-module`](https://github.com/logos-co/logos-delivery-module) | Transport (Waku) — runtime dependency, pinned at v0.1.3 | | [`libchat`](https://github.com/logos-messaging/libchat) | Chat engine embedded by `chat_module` (E2EE, sessions) | | [`logos-module-builder`](https://github.com/logos-co/logos-module-builder) | Module build system | | [`logos-liblogos`](https://github.com/logos-co/logos-liblogos) | Logos Core platform | diff --git a/doctests/chat-ui-group.test.yaml b/doctests/chat-ui-group.test.yaml new file mode 100644 index 0000000..51f7562 --- /dev/null +++ b/doctests/chat-ui-group.test.yaml @@ -0,0 +1,81 @@ +name: "Tutorial: Run the automated group-chat test" +output: chat-ui-group.md +project_name: logos-chat-ui +release: "" + +intro: | + A group conversation goes further than a 1:1 chat: one person creates it, + invites others by address, and every message fans out to the whole roster with + the sender attributed. That spans **three** app instances and grows by + membership commits over the live network — more than the single-window + walkthrough in *The Logos Chat UI* tutorial can express, and more than the + doc-test `ui_test` driver can orchestrate on its own (it works against one + instance and can't read one window's address out to invite it into another). + + `logos-chat-ui` ships an automated test for exactly this. The `group` app + drives the real three-party flow over the live network between three headless + instances — Alice creates the group, invites Bob and Carol, and once the roster + converges sends a message that reaches both — then exposes the newest member's + window so the finished group can be captured. + +what_you_build: "A verified three-member, end-to-end-encrypted group conversation across three chat UI instances, captured from a receiving member's window." + +what_you_learn: + - "How the `group` app forms a three-member group and fans a message out to it, headless over the live network" + - "How the group UI adds a members pane and per-message sender attribution over a 1:1 chat" + - "Why a multi-window group needs a dedicated runner rather than the single-instance `ui_test` driver" + +prerequisites: + - | + **Nix** with flakes enabled. Install from [nixos.org](https://nixos.org/download.html), then: + + ```bash + mkdir -p ~/.config/nix + echo 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf + ``` + - "**A Linux or macOS machine.** Nix provides every toolchain (Qt, CMake, Rust) during the builds." + - "**Network access.** The three delivery nodes discover each other over the configured network preset; an isolated sandbox won't complete the flow. Expect membership changes to take minutes." + +sections: + - title: "Run the automated group chat" + step: true + text: | + The `group` app launches three instances offscreen and runs the real flow + over the live network — Alice creates the group, invites Bob and Carol, + and once all three are in she sends a message that fans out to both — then + opens the capture port onto Carol's live window so her copy of the group is + on screen. The whole flow happens while the launch is still coming up, so + the screenshot below captures the already-formed group: the creator's + message, received with its sender label, beside the three-member roster. + steps: + - title: "Run the group flow and show the result" + ui_test: + setup: + - "nix build 'github:logos-co/logos-qt-mcp{release}' -o result-mcp" + qt_mcp: "result-mcp" + # Opens the default inspector port 3768: the qt-mcp test framework + # always connects to QML_INSPECTOR_PORT (default 3768), so the captured + # window must be reachable there. The group flow runs on its own + # inspector ports (4768/4769/4770) during this (unbounded) launch wait; + # 3768 opens (proxied onto Carol's live window) only once the flow is + # done, so the capture below runs against the already-finished window. + # Larger than the exchange's 1800: forming a three-member group runs + # two sequential membership commits plus the fan-out before Carol's + # window opens on 3768, so the port-wait must cover a longer flow. + launch: "nix run 'github:logos-co/logos-chat-ui{release}#group'" + launch_timeout: 2700 + tests: + - name: "Group message received" + action: wait_for + # Exact strings — the inspector's findByProperty matches the whole + # `text` property, not a substring (verified against logos-qt-mcp). + # "Hello team 👋" mirrors run-group.mjs's GROUP_MSG; "members" is + # the members-pane header, present only for a group conversation. + texts: ["Hello team 👋", "members"] + timeout: 90000 + text: | + Carol's window after the group formed: Alice's message, received + with its sender label and end-to-end-encrypted over + `delivery_module`, beside the members pane listing the + three-member roster. + screenshot: "group.png" diff --git a/doctests/group/port-proxy.mjs b/doctests/group/port-proxy.mjs new file mode 100644 index 0000000..d4cc008 --- /dev/null +++ b/doctests/group/port-proxy.mjs @@ -0,0 +1,34 @@ +// Expose an already-listening local TCP port on another one: accept +// connections on LISTEN_PORT and pipe each to 127.0.0.1:TARGET_PORT. +// +// Used by run-exchange-show.sh to gate the doc-test's capture port: the app +// binds its QML inspector the moment it starts, so the only way to make "the +// port is open" mean "the exchange is finished" is to run the exchange on a +// private inspector port and open the capture port afterwards, as this proxy +// onto the live window. +// +// Env: +// LISTEN_PORT port to listen on +// TARGET_PORT local port to forward to +import net from "node:net"; + +const listenPort = parseInt(process.env.LISTEN_PORT, 10); +const targetPort = parseInt(process.env.TARGET_PORT, 10); +if (!listenPort || !targetPort) { + console.error("port-proxy: LISTEN_PORT and TARGET_PORT are required"); + process.exit(1); +} + +const server = net.createServer((client) => { + const upstream = net.createConnection({ host: "127.0.0.1", port: targetPort }); + client.pipe(upstream); + upstream.pipe(client); + const drop = () => { client.destroy(); upstream.destroy(); }; + client.on("error", drop); + upstream.on("error", drop); + client.on("close", drop); + upstream.on("close", drop); +}); +server.listen(listenPort, "127.0.0.1", () => { + console.log(`port-proxy: ${listenPort} -> ${targetPort}`); +}); diff --git a/doctests/group/run-group-show.sh b/doctests/group/run-group-show.sh new file mode 100755 index 0000000..f579587 --- /dev/null +++ b/doctests/group/run-group-show.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Role: doc-test display entry (the `#group` flake app); wraps run-group.sh then holds the window open. +# Run the real three-party group conversation, then hold the newest member's +# window open showing the result — the entry point for the `#group` flake app the +# doc-test drives. +# +# A group forms slowly (three delivery nodes finding each other, then two +# membership commits over the network), while the doc-test caps its capture step +# at 120s. So this splits the work: the slow group flow runs first, on its own +# inspector ports, and all three instances are *kept alive* (chats are ephemeral +# — the module persists nothing, so there is no history to reload a fresh window +# from). Only once the flow is done does SHOW_PORT open, as a TCP proxy onto +# Carol's live inspector. The doc-test attaches to that finished window and +# screenshots it well within its budget — the flow happens during the unbounded +# `launch_timeout` port-wait, before SHOW_PORT ever opens. +# +# Usage: +# run-group-show.sh [base-dir] +# Env: +# APP_BIN run-logos-standalone-ui path (baked in by the flake app) +# SHOW_PORT capture port the doc-test attaches to (default 3768) +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BASE="${1:-$(mktemp -d "${TMPDIR:-/tmp}/chat-group-show.XXXXXX")}" +DATA_DIR="$BASE/data" +OUT_DIR="${OUT_DIR:-$BASE/images}" +SHOW_PORT="${SHOW_PORT:-3768}" +# Carol (the last member to join) is the window we expose: her screen shows the +# creator's message received with a sender label beside the full roster. +# The flow runs on 4778-4780 / delivery 60020-60022 — distinct from the #exchange +# app's 4768-4769 / 60010-60011, so the two don't collide when the doc-test runs +# both specs in one job and a prior app's setsid'd module hosts outlive teardown. +CAROL_PORT=4780 +mkdir -p "$DATA_DIR" "$OUT_DIR" + +# All three exchange instances outlive run-group.sh here (KEEP_INSTANCES), so +# their teardown is ours: snapshot the logos process set before launch and, on +# exit, kill exactly the processes this run added (the module host processes set +# their own session, so a process-group kill would miss them). Plus the proxy. +LOGOS_PAT='logos_host_qt|logos-standalone-app|ui-host' +PRE_PIDS="$(pgrep -f "$LOGOS_PAT" 2>/dev/null | sort -u || true)" +PROXY_PID="" +show_cleanup() { + [ -n "$PROXY_PID" ] && kill "$PROXY_PID" 2>/dev/null || true + local now ours + now="$(pgrep -f "$LOGOS_PAT" 2>/dev/null | sort -u || true)" + ours="$(comm -13 <(printf '%s\n' "$PRE_PIDS") <(printf '%s\n' "$now") || true)" + [ -n "$ours" ] && kill -9 $ours 2>/dev/null || true +} +trap 'exit 0' TERM INT +trap show_cleanup EXIT + +# Phase 1: the real group flow, on inspector ports 4768/4769/4770 so SHOW_PORT +# stays closed until the finished window is ready. KEEP_INSTANCES leaves all +# three running with the completed group on screen. +echo "=== phase 1: three-party group chat (instances kept alive) ===" +ALICE_PORT=4778 BOB_PORT=4779 CAROL_PORT="$CAROL_PORT" \ + ALICE_DELIVERY=60020 BOB_DELIVERY=60021 CAROL_DELIVERY=60022 \ + WORK_DIR="$DATA_DIR" KEEP_WORK_DIR=1 KEEP_INSTANCES=1 OUT_DIR="$OUT_DIR" \ + bash "$here/run-group.sh" "$OUT_DIR" + +# Phase 2: make sure Carol's window still shows the group thread and roster +# before it is exposed to the doc-test. +echo "=== phase 2: verify the group is on screen ===" +SHOW_PORT="$CAROL_PORT" node "$here/select-group.mjs" + +# Phase 3: open SHOW_PORT onto Carol's live inspector and hold the window for the +# doc-test to screenshot. The doc-test kills this process group when done; the +# EXIT trap reaps the proxy and all three instances. Probe the port before +# declaring ready: a proxy that failed to bind (e.g. SHOW_PORT already in use) +# would otherwise turn into a silent hold that never opens the port. +LISTEN_PORT="$SHOW_PORT" TARGET_PORT="$CAROL_PORT" node "$here/port-proxy.mjs" & +PROXY_PID=$! +proxy_up="" +for _ in $(seq 1 10); do + if (exec 3<>"/dev/tcp/127.0.0.1/$SHOW_PORT") 2>/dev/null; then proxy_up=1; break; fi + sleep 1 +done +[ -n "$proxy_up" ] || { echo "proxy on port $SHOW_PORT never came up" >&2; exit 1; } +echo "=== ready: group complete; exposing Carol on port $SHOW_PORT ===" +while true; do sleep 3600 & wait $!; done diff --git a/doctests/group/run-group.mjs b/doctests/group/run-group.mjs new file mode 100644 index 0000000..1493a4f --- /dev/null +++ b/doctests/group/run-group.mjs @@ -0,0 +1,208 @@ +// Drive a real three-party GROUP conversation across three logos-chat-ui +// instances and capture screenshots for the docs. +// +// A group chat spans more than two windows and grows by membership commits over +// the live network — something the single-instance doc-test ui_test driver can +// neither orchestrate nor read runtime addresses out of. This driver speaks the +// logos-qt-mcp inspector protocol to all three instances: Alice creates a group, +// invites Bob and Carol by address, waits for the roster to converge, then sends +// one message that fans out to both — exercising the members pane and the +// per-message sender attribution the group UI adds over a 1:1 chat. Launch and +// teardown of the three apps is handled by run-group.sh; this script attaches to +// the three inspector ports it is given. +// +// Env: +// ALICE_PORT / BOB_PORT / CAROL_PORT inspector ports (default 3768/3769/3770) +// OUT_DIR screenshot output dir (default ./images) +import net from "node:net"; +import fs from "node:fs"; + +// ── inspector client: newline-delimited JSON over TCP, one socket per port ── +class Inspector { + constructor(host, port) { this.host = host; this.port = port; this.socket = null; this.requestId = 0; this.pending = new Map(); this.buffer = ""; } + async connect() { + if (this.socket && !this.socket.destroyed) return; + return new Promise((resolve, reject) => { + const sock = net.createConnection({ host: this.host, port: this.port }); + sock.once("connect", () => { this.socket = sock; resolve(); }); + sock.once("error", (err) => reject(new Error(`connect ${this.port}: ${err.message}`))); + sock.on("data", (c) => { this.buffer += c.toString("utf-8"); this._drain(); }); + sock.on("close", () => { this.socket = null; for (const [, p] of this.pending) { clearTimeout(p.timer); p.reject(new Error("connection closed")); } this.pending.clear(); }); + sock.on("error", () => {}); + }); + } + _drain() { let i; while ((i = this.buffer.indexOf("\n")) !== -1) { const line = this.buffer.slice(0, i).trim(); this.buffer = this.buffer.slice(i + 1); if (!line) continue; try { const m = JSON.parse(line); const p = this.pending.get(String(m.id)); if (p) { clearTimeout(p.timer); this.pending.delete(String(m.id)); p.resolve(m); } } catch {} } } + async send(command, params = {}) { + await this.connect(); + const id = ++this.requestId; + const payload = JSON.stringify({ id, command, params }) + "\n"; + return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(String(id)); reject(new Error(`inspector timeout: ${command}`)); }, 30000); this.pending.set(String(id), { resolve, reject, timer }); this.socket.write(payload); }); + } + disconnect() { if (this.socket) this.socket.destroy(); } +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// Evaluate a QML expression in ChatView's root scope (the ids d, convList, +// msgList, addressDialog are all in scope there; d.memberModel is the roster). +async function evalq(insp, expr) { + const r = await insp.send("evaluate", { expression: expr }); + if (r.error) throw new Error(`eval(${expr}): ${r.error}`); + return r.result; +} + +async function waitFor(fn, { timeout = 180000, interval = 1000, what = "condition" } = {}) { + const start = Date.now(); let last; + while (Date.now() - start < timeout) { + try { if (await fn()) return; } catch (e) { last = e; } + await sleep(interval); + } + throw new Error(`timed out waiting for ${what}${last ? " (" + last.message + ")" : ""}`); +} + +async function shoot(insp, outDir, name) { + const r = await insp.send("screenshot", {}); + if (r.error || !r.image) throw new Error(`screenshot ${name}: ${r.error || "no image"}`); + fs.writeFileSync(`${outDir}/${name}`, Buffer.from(r.image, "base64")); + console.log(` screenshot ${name} (${r.width}x${r.height})`); +} + +// Select a conversation and wait for its thread to populate. The first +// get_messages can time out while chat_module is still busy with the network +// send, so re-select (deselect first — select is a no-op when already current) +// until the thread shows at least `minCount` messages. +async function loadThread(insp, convId, minCount, { timeout = 120000 } = {}) { + const start = Date.now(); + while (Date.now() - start < timeout) { + await evalq(insp, `d.backend.selectConversation("")`); + await sleep(400); + await evalq(insp, `d.backend.selectConversation(${JSON.stringify(convId)})`); + const innerStart = Date.now(); + while (Date.now() - innerStart < 8000) { + const c = await evalq(insp, "msgList.count"); + if (typeof c === "number" && c >= minCount) return c; + await sleep(1000); + } + } + throw new Error(`conversation thread did not reach ${minCount} message(s)`); +} + +// Poll the roster until it lists at least `minCount` members. refreshMembers is +// idempotent — it re-reads list_group_members for the current group — so drive +// it from here rather than waiting on the membership-update event to land. It +// no-ops unless the group is the current conversation, so the caller must have +// selected it first. +async function waitForRoster(insp, minCount, { timeout = 150000, what = "roster" } = {}) { + const start = Date.now(); let last = 0; + while (Date.now() - start < timeout) { + try { + // memberCount is a backend property (reliably remoted), unlike the member + // model's rowCount() over its QtRO replica; refreshMembers re-reads the + // roster and updates it. + await evalq(insp, "d.backend.refreshMembers()"); + last = await evalq(insp, "d.backend.memberCount"); + if (typeof last === "number" && last >= minCount) return last; + } catch {} + await sleep(2000); + } + throw new Error(`${what} did not reach ${minCount} members (last ${last})`); +} + +// Ask for the instance's address, wait for it to surface, then close the dialog +// so it doesn't sit over the window. requestMyAddress triggers a synchronous +// get_address RPC; the address arrives in addressDialog.addressText via the +// addressReady signal. +async function getAddress(insp) { + await evalq(insp, `addressDialog.addressText = ""`); + await evalq(insp, "d.backend.requestMyAddress()"); + const start = Date.now(); let addr = ""; + while (Date.now() - start < 25000) { + const t = await evalq(insp, "addressDialog.addressText"); + if (typeof t === "string" && t.length > 0) { addr = t; break; } + await sleep(1000); + } + await evalq(insp, "addressDialog.close()"); + if (!addr) throw new Error("address was not produced"); + return addr; +} + +const CONV_ID_ROLE = 257; // ConversationListModel::ConversationIdRole (Qt::UserRole + 1) +// Must match doctests/chat-ui-group.test.yaml's wait_for text exactly: +// findByProperty matches the whole `text` property, not a substring. +const GROUP_MSG = "Hello team \u{1F44B}"; + +async function main() { + const outDir = process.env.OUT_DIR || "./images"; + fs.mkdirSync(outDir, { recursive: true }); + const alice = new Inspector("127.0.0.1", parseInt(process.env.ALICE_PORT || "3768", 10)); + const bob = new Inspector("127.0.0.1", parseInt(process.env.BOB_PORT || "3769", 10)); + const carol = new Inspector("127.0.0.1", parseInt(process.env.CAROL_PORT || "3770", 10)); + await alice.connect(); await bob.connect(); await carol.connect(); + console.log("connected to all three inspectors"); + + // Three full UI stacks (each with its own waku node) boot and peer on one host, + // so allow more time to reach Online than the two-party exchange needs. + console.log("waiting for all instances to reach Online..."); + await waitFor(async () => (await evalq(alice, "d.statusText()")) === "Online", { timeout: 300000, what: "alice Online" }); + await waitFor(async () => (await evalq(bob, "d.statusText()")) === "Online", { timeout: 300000, what: "bob Online" }); + await waitFor(async () => (await evalq(carol, "d.statusText()")) === "Online", { timeout: 300000, what: "carol Online" }); + console.log("all Online"); + // A chat_module RPC issued the instant after Online can time out while the + // module is still settling; let it quiesce before the first call. + await sleep(3000); + + console.log("bob/carol: read their addresses..."); + const bobAddr = await getAddress(bob); + const carolAddr = await getAddress(carol); + console.log(`bob address: ${bobAddr.length} chars, carol address: ${carolAddr.length} chars`); + + console.log("alice: create the group..."); + await evalq(alice, "d.backend.createGroupConversation()"); + await waitFor(async () => (await evalq(alice, "d.backend.currentConversationId")) !== "", { timeout: 30000, what: "group created" }); + const groupId = await evalq(alice, "d.backend.currentConversationId"); + console.log(`group id: ${groupId}`); + + // Membership grows one commit at a time; wait for each invitee to actually + // join (their conversation list shows the group) before the next invite, so + // the second add starts from a settled group rather than racing the first. + console.log("alice: invite bob..."); + await evalq(alice, `d.backend.addGroupMember(${JSON.stringify(groupId)}, ${JSON.stringify(bobAddr)})`); + await waitFor(async () => (await evalq(bob, "convList.count")) >= 1, { timeout: 300000, what: "bob joins the group" }); + console.log("bob joined"); + + console.log("alice: invite carol..."); + await evalq(alice, `d.backend.addGroupMember(${JSON.stringify(groupId)}, ${JSON.stringify(carolAddr)})`); + await waitFor(async () => (await evalq(carol, "convList.count")) >= 1, { timeout: 300000, what: "carol joins the group" }); + console.log("carol joined"); + + console.log("alice: wait for the roster to converge to three..."); + await waitForRoster(alice, 3, { what: "alice's roster" }); + await shoot(alice, outDir, "01-alice-group.png"); + + console.log("alice: send the group message..."); + await evalq(alice, `d.backend.sendMessage(${JSON.stringify(groupId)}, ${JSON.stringify(GROUP_MSG)})`); + await loadThread(alice, groupId, 1); + console.log("alice: message sent"); + await shoot(alice, outDir, "02-alice-sent.png"); + + // Carol is the last to join; her window is the group story end to end — the + // creator's message received with a sender label, beside the three-member + // roster. Load her thread first (leaves the group selected), then converge + // her roster. + console.log("carol: open the group and wait for alice's message..."); + const carolGroup = await evalq(carol, `d.convModel.data(d.convModel.index(0,0), ${CONV_ID_ROLE})`); + await loadThread(carol, carolGroup, 1, { timeout: 300000 }); + await waitForRoster(carol, 3, { what: "carol's roster" }); + console.log("carol: received the group message"); + await shoot(carol, outDir, "03-carol-received.png"); + + console.log("bob: confirm the message fanned out to him too..."); + const bobGroup = await evalq(bob, `d.convModel.data(d.convModel.index(0,0), ${CONV_ID_ROLE})`); + await loadThread(bob, bobGroup, 1, { timeout: 300000 }); + await shoot(bob, outDir, "04-bob-received.png"); + + alice.disconnect(); bob.disconnect(); carol.disconnect(); + console.log("\nGROUP COMPLETE: three-member group formed, message fanned out to both peers; screenshots in " + outDir); +} + +main().then(() => process.exit(0)).catch((e) => { console.error("\nFAILED:", e.message); process.exit(1); }); diff --git a/doctests/group/run-group.sh b/doctests/group/run-group.sh new file mode 100755 index 0000000..aaee027 --- /dev/null +++ b/doctests/group/run-group.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# Role: headless three-party group-chat test driver (CI); see run-group-show.sh for the doc-test display variant. +# Regenerate the three-instance group-chat screenshots for the docs. +# +# Launches three logos-chat-ui instances offscreen — each with its own data dir, +# delivery-node port, and QML inspector port — drives a real group conversation +# between them via the logos-qt-mcp inspector protocol (run-group.mjs): Alice +# creates the group, invites Bob and Carol, and once the roster converges sends a +# message that fans out to both. Writes the screenshots to OUT_DIR and exits +# non-zero if the flow does not complete, so this doubles as an end-to-end +# integration check. The three instances coexist because each picks a random +# QtRO socket name and we give each a distinct data dir / delivery port / +# inspector port. +# +# Usage: +# doctests/group/run-group.sh [out-dir] +# Env: +# FLAKE flake ref to build the app from (default ".") +# APP_BIN run-logos-standalone-ui path; if unset, built from FLAKE +# OUT_DIR screenshot dir (default arg1, else docs/images/group) +# WORK_DIR per-instance data/log dir; if unset, a fresh mktemp is used +# KEEP_WORK_DIR if set, WORK_DIR is left in place on exit (e.g. to keep the +# app logs); processes are still torn down +# KEEP_INSTANCES if set, a *successful* run leaves the three instances running +# (and their WORK_DIR in place) — the caller owns their +# teardown; a failed run still tears everything down +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$here/../.." && pwd)" +OUT_DIR="${OUT_DIR:-${1:-$repo_root/docs/images/group}}" +FLAKE="${FLAKE:-$repo_root}" +ALICE_PORT="${ALICE_PORT:-3768}" +BOB_PORT="${BOB_PORT:-3769}" +CAROL_PORT="${CAROL_PORT:-3770}" +ALICE_DELIVERY="${ALICE_DELIVERY:-60010}" +BOB_DELIVERY="${BOB_DELIVERY:-60011}" +CAROL_DELIVERY="${CAROL_DELIVERY:-60012}" +WORK_DIR="${WORK_DIR:-$(mktemp -d "${TMPDIR:-/tmp}/chat-group.XXXXXX")}" +mkdir -p "$OUT_DIR" "$WORK_DIR" + +if [ -z "${APP_BIN:-}" ]; then + echo "resolving the standalone app launcher from $FLAKE ..." + system="$(nix eval --raw --impure --expr builtins.currentSystem)" + APP_BIN="$(nix eval --raw "$FLAKE#apps.$system.default.program")" + # Build the launcher's *derivation*, not its output path. `nix build + # /nix/store/` has no .drv to build from and can only substitute, so a + # binary-cache miss fails with "no substituter that can build it". Recover the + # drv from the SAME flake eval as APP_BIN (getContext over its program string) + # so the build target matches the launch target and a miss builds from source. + app_drv="$(nix eval --raw --apply \ + 'p: builtins.head (builtins.attrNames (builtins.getContext p))' \ + "$FLAKE#apps.$system.default.program")" + nix build --no-link "$app_drv^*" +fi +echo "app: $APP_BIN" + +# The app fans out into one host process per module (logos_host_qt for +# capability_module / chat_module / delivery_module, plus ui-host), each in its +# own session — so killing the launched PID's process group misses them. Snapshot +# the logos process set before launch and, on exit, kill exactly the processes +# our run added (the set difference), which never touches pre-existing instances. +LOGOS_PAT='logos_host_qt|logos-standalone-app|ui-host' +PRE_PIDS="$(pgrep -f "$LOGOS_PAT" 2>/dev/null | sort -u || true)" +group_done="" +cleanup() { + if [ -n "${KEEP_INSTANCES:-}" ] && [ -n "$group_done" ]; then + return # the caller owns the still-running instances and their WORK_DIR + fi + local now ours + now="$(pgrep -f "$LOGOS_PAT" 2>/dev/null | sort -u || true)" + ours="$(comm -13 <(printf '%s\n' "$PRE_PIDS") <(printf '%s\n' "$now") || true)" + [ -n "$ours" ] && kill -9 $ours 2>/dev/null || true + [ -n "${KEEP_WORK_DIR:-}" ] || rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +launch() { + local name="$1" inspector="$2" delivery="$3" + mkdir -p "$WORK_DIR/$name" + QT_QPA_PLATFORM=offscreen QT_FORCE_STDERR_LOGGING=1 \ + QML_INSPECTOR_PORT="$inspector" \ + CHAT_MODULE_INSTANCE_PATH="$WORK_DIR/$name" \ + CHAT_MODULE_DELIVERY_PORT="$delivery" \ + setsid "$APP_BIN" -platform offscreen > "$WORK_DIR/$name.log" 2>&1 & + echo "launched $name (inspector $inspector, delivery $delivery)" +} + +# Surface each instance's boot/runtime output — the apps launch headless, so +# their logs are the only window into a node that failed to boot or to reach the +# network (e.g. a delivery node still peering, or one that crashed under the load +# of three full stacks on one host). +dump_app_logs() { + for f in "$WORK_DIR"/*.log; do + [ -f "$f" ] || continue + echo "::group::app log $(basename "$f")" >&2 + cat "$f" >&2 + echo "::endgroup::" >&2 + done +} + +wait_for_port() { + local port="$1" + for _ in $(seq 1 120); do + (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null && return 0 + sleep 1 + done + echo "inspector on port $port never came up" >&2 + dump_app_logs + return 1 +} + +launch alice "$ALICE_PORT" "$ALICE_DELIVERY" +launch bob "$BOB_PORT" "$BOB_DELIVERY" +launch carol "$CAROL_PORT" "$CAROL_DELIVERY" +wait_for_port "$ALICE_PORT" +wait_for_port "$BOB_PORT" +wait_for_port "$CAROL_PORT" + +# Dump the app logs on any driver failure (e.g. a node that never reaches +# Online) before the EXIT trap tears the work dir down — they show whether a node +# crashed or was still connecting. +if ! OUT_DIR="$OUT_DIR" ALICE_PORT="$ALICE_PORT" BOB_PORT="$BOB_PORT" CAROL_PORT="$CAROL_PORT" \ + node "$here/run-group.mjs"; then + dump_app_logs + exit 1 +fi +group_done=1 diff --git a/doctests/group/select-group.mjs b/doctests/group/select-group.mjs new file mode 100644 index 0000000..3cc2824 --- /dev/null +++ b/doctests/group/select-group.mjs @@ -0,0 +1,78 @@ +// Select the group conversation in an already-running logos-chat-ui instance, +// over the logos-qt-mcp inspector protocol, then exit leaving it shown with its +// roster loaded. +// +// Used by run-group-show.sh: after the three-party group flow completes, Carol's +// live window is about to be exposed to the doc-test's capture step. This picks +// the conversation, opens its thread, and refreshes the roster so the screenshot +// shows the received message beside the members pane — without needing to know +// the conversation id (which is generated at runtime). +// +// Env: +// SHOW_PORT inspector port to attach to (default 3768) +import net from "node:net"; + +// Newline-delimited JSON over TCP, one request/response per id. +class Inspector { + constructor(host, port) { this.host = host; this.port = port; this.socket = null; this.requestId = 0; this.pending = new Map(); this.buffer = ""; } + connect() { + return new Promise((resolve, reject) => { + const sock = net.createConnection({ host: this.host, port: this.port }); + sock.once("connect", () => { this.socket = sock; resolve(); }); + sock.once("error", (err) => reject(new Error(`connect ${this.port}: ${err.message}`))); + sock.on("data", (c) => { this.buffer += c.toString("utf-8"); this._drain(); }); + sock.on("error", () => {}); + }); + } + _drain() { let i; while ((i = this.buffer.indexOf("\n")) !== -1) { const line = this.buffer.slice(0, i).trim(); this.buffer = this.buffer.slice(i + 1); if (!line) continue; try { const m = JSON.parse(line); const p = this.pending.get(String(m.id)); if (p) { clearTimeout(p.timer); this.pending.delete(String(m.id)); p.resolve(m); } } catch {} } } + send(command, params = {}) { + const id = ++this.requestId; + return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(String(id)); reject(new Error(`inspector timeout: ${command}`)); }, 30000); this.pending.set(String(id), { resolve, reject, timer }); this.socket.write(JSON.stringify({ id, command, params }) + "\n"); }); + } + disconnect() { if (this.socket) this.socket.destroy(); } +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// Evaluate a QML expression in ChatView's root scope (ids d, convList, msgList). +async function evalq(insp, expr) { + const r = await insp.send("evaluate", { expression: expr }); + if (r.error) throw new Error(`eval(${expr}): ${r.error}`); + return r.result; +} + +async function waitFor(insp, expr, pred, { timeout = 90000, interval = 1000, what = "condition" } = {}) { + const start = Date.now(); let last; + while (Date.now() - start < timeout) { + try { const v = await evalq(insp, expr); if (pred(v)) return v; } catch (e) { last = e; } + await sleep(interval); + } + throw new Error(`timed out waiting for ${what}${last ? " (" + last.message + ")" : ""}`); +} + +const CONV_ID_ROLE = 257; // ConversationListModel::ConversationIdRole (Qt::UserRole + 1) + +async function main() { + const insp = new Inspector("127.0.0.1", parseInt(process.env.SHOW_PORT || "3768", 10)); + await insp.connect(); + + await waitFor(insp, "convList.count", (c) => typeof c === "number" && c >= 1, + { timeout: 90000, what: "conversation to be listed" }); + + const convId = await evalq(insp, `d.convModel.data(d.convModel.index(0,0), ${CONV_ID_ROLE})`); + await evalq(insp, `d.backend.selectConversation(${JSON.stringify(convId)})`); + + // Wait for the thread to populate so the held window shows the message. + await waitFor(insp, "msgList.count", (c) => typeof c === "number" && c >= 1, + { timeout: 60000, what: "conversation thread to load" }); + // Refresh the roster so the members pane is populated in the screenshot. + // memberCount is a backend property, reliable unlike the model replica's rowCount. + await evalq(insp, "d.backend.refreshMembers()"); + await waitFor(insp, "d.backend.memberCount", (c) => typeof c === "number" && c >= 1, + { timeout: 30000, what: "roster to load" }); + + console.log(`selected group ${convId}; thread and roster shown`); + insp.disconnect(); +} + +main().then(() => process.exit(0)).catch((e) => { console.error("select-group FAILED:", e.message); process.exit(1); }); diff --git a/flake.lock b/flake.lock index 3142a48..2acc141 100644 --- a/flake.lock +++ b/flake.lock @@ -6,17 +6,17 @@ "logos-module-builder": "logos-module-builder_4" }, "locked": { - "lastModified": 1783336406, - "narHash": "sha256-JV2coaXvtNSL5tNZSC9Ji1jWL3iWBY2DrwFKmM8ckTI=", + "lastModified": 1783668326, + "narHash": "sha256-7yLbzqRtpLHRFFT2R3n+sijJhoErFkRX2Qp2XBbsy4A=", "owner": "logos-co", "repo": "logos-chat-module", - "rev": "5c79789041c75bd940bdbc5720a2c9274df09b35", + "rev": "c9542d8fa719dbcb04a1b099986256500b20d56a", "type": "github" }, "original": { "owner": "logos-co", "repo": "logos-chat-module", - "rev": "5c79789041c75bd940bdbc5720a2c9274df09b35", + "rev": "c9542d8fa719dbcb04a1b099986256500b20d56a", "type": "github" } }, diff --git a/flake.nix b/flake.nix index b794c80..0c85220 100644 --- a/flake.nix +++ b/flake.nix @@ -6,10 +6,11 @@ # logos-protocol/logos-qt-sdk chain matches across both. logos-module-builder.url = "github:logos-co/logos-module-builder"; nix-bundle-lgx.url = "github:logos-co/nix-bundle-lgx"; - # Pinned to the master rev carrying the DirectV1 migration (chat-module #37), - # which isn't in a release tag yet; re-pin to the tag once one is cut. Delivery - # stays on the v0.1.3 tag below, matching chat_module's own delivery pin. - chat_module.url = "github:logos-co/logos-chat-module/5c79789041c75bd940bdbc5720a2c9274df09b35"; + # Pinned to the merged GroupV2 commit on chat_module master (#43); re-pin + # whenever chat_module advances, and switch to a release tag once one is + # cut. Delivery stays on the v0.1.3 tag below, matching chat_module's own + # delivery pin. + chat_module.url = "github:logos-co/logos-chat-module/c9542d8fa719dbcb04a1b099986256500b20d56a"; # Pinned to the v0.1.3 release tag, which includes the zerokit/RLN nix build # fix (delivery-module #49: zerokit's cargo vendor no longer hits crates.io's # python-requests 403). Kept in lockstep with chat_module's delivery pin. @@ -42,11 +43,33 @@ exec bash ${./doctests/exchange}/run-exchange-show.sh "$@" ''; }; + + # `nix run .#group`: form a real three-party group conversation and hold the + # newest member's window open showing the result. The doc-test launches this + # to capture one screenshot of the formed group (see + # doctests/chat-ui-group.test.yaml). APP_BIN is this flake's standalone + # runner; the driver scripts are bundled from ./doctests/group. + groupApp = system: + let + pkgs = import nixpkgs { inherit system; }; + runner = pkgs.writeShellApplication { + name = "chat-ui-group"; + runtimeInputs = with pkgs; [ nodejs coreutils util-linux procps bash ]; + text = '' + export APP_BIN="${base.apps.${system}.default.program}" + exec bash ${./doctests/group}/run-group-show.sh "$@" + ''; + }; + in { + type = "app"; + program = "${runner}/bin/chat-ui-group"; + }; in base // { apps = builtins.mapAttrs (system: sysApps: sysApps // { exchange = { type = "app"; program = "${exchangeRunner system}/bin/chat-ui-exchange"; }; + group = groupApp system; }) base.apps; # Also a package so `nix build .#exchange` resolves: the doc-test runner diff --git a/metadata.json b/metadata.json index 507c7bd..1cd5457 100644 --- a/metadata.json +++ b/metadata.json @@ -1,7 +1,7 @@ { "name": "chat_ui", "display_name": "Chat", - "version": "0.1.2", + "version": "0.2.0", "type": "ui_qml", "interface": "universal", "category": "chat", diff --git a/src/ChatBackend.cpp b/src/ChatBackend.cpp index beea0b5..f04fec5 100644 --- a/src/ChatBackend.cpp +++ b/src/ChatBackend.cpp @@ -1,6 +1,7 @@ #include "ChatBackend.h" #include "ConversationListModel.h" #include "MessageListModel.h" +#include "MemberListModel.h" // Generated umbrella: LogosModules (behind modules()) from // metadata.json#dependencies — the Qt-typed chat_module wrapper. @@ -31,12 +32,14 @@ ChatBackend::ChatBackend(QObject* parent) : ChatBackendSimpleSource(parent) , m_conversationModel(new ConversationListModel(this)) , m_messageModel(new MessageListModel(this)) + , m_memberModel(new MemberListModel(this)) , m_instancePath(resolveInstancePath()) { setChatStatus(ChatBackendSimpleSource::Stopped); setMyIdentity(QString()); setStatusMessage(QStringLiteral("Ready")); setCurrentConversationId(QString()); + syncCurrentConversationMeta(); } void ChatBackend::onContextReady() @@ -63,6 +66,11 @@ MessageListModel* ChatBackend::messageModel() const return m_messageModel; } +MemberListModel* ChatBackend::memberModel() const +{ + return m_memberModel; +} + // ── instance path ─────────────────────────────────────────────────────────── // The chat_module's own instance directory, passed to it via init(). The UI @@ -175,11 +183,26 @@ void ChatBackend::rehydrateConversations() if (convoId.isEmpty()) continue; const QString nickname = obj.value(QStringLiteral("nickname")).toString(); const qint64 lastActivity = obj.value(QStringLiteral("last_activity_ms")).toLongLong(); + const bool isGroup = obj.value(QStringLiteral("kind")).toString() == QStringLiteral("group"); m_conversationModel->addConversation(convoId, - nickname.isEmpty() ? fallbackDisplayName(convoId) - : nickname, - msToDateTime(lastActivity)); + nickname.isEmpty() + ? fallbackDisplayName(convoId, QString(), isGroup) + : nickname, + msToDateTime(lastActivity), isGroup); } + // The rebuilt list may now know the current conversation's kind/name. + syncCurrentConversationMeta(); +} + +// Push the current conversation's derived view state (group flag, display name) +// as backend properties. The models reach QML as QtRO replicas that don't proxy +// ConversationListModel's isGroupFor/displayNameFor Q_INVOKABLE lookups, so the +// view binds these instead. Call whenever the selection or the list changes. +void ChatBackend::syncCurrentConversationMeta() +{ + const QString id = currentConversationId(); + setCurrentIsGroup(m_conversationModel->isGroupFor(id)); + setCurrentDisplayName(m_conversationModel->displayNameFor(id)); } void ChatBackend::showConversationMessages(const QString& convoId) @@ -195,7 +218,8 @@ void ChatBackend::showConversationMessages(const QString& convoId) const bool fromSelf = obj.value(QStringLiteral("from_self")).toBool(); const QString content = obj.value(QStringLiteral("content")).toString(); const qint64 ts = obj.value(QStringLiteral("timestamp_ms")).toLongLong(); - rows.append({ fromSelf ? QStringLiteral("Me") : QStringLiteral("Peer"), + const QString sender = obj.value(QStringLiteral("sender")).toString(); + rows.append({ fromSelf ? QStringLiteral("Me") : shortSenderLabel(sender), content, msToDateTime(ts), fromSelf }); } m_messageModel->addMessages(std::move(rows)); @@ -230,6 +254,47 @@ void ChatBackend::createConversation(QString peerAddress) // appliers handle the UI side from there. } +void ChatBackend::createGroupConversation() +{ + if (chatStatus() != ChatBackendSimpleSource::Online || !isContextReady()) { + emit error(QStringLiteral("Chat not online")); + return; + } + + setStatusMessage(QStringLiteral("Creating group...")); + const LogosResult res = modules().chat_module.create_group_conversation(); + if (!res.success) { + const QString reason = res.getError(); + setStatusMessage(QStringLiteral("Failed to create group: ") + reason); + emit error(QStringLiteral("Failed to create group: ") + reason); + } + // The group starts with only this member; conversation_created selects it. + // Members are added afterwards from the members panel. +} + +void ChatBackend::addGroupMember(QString conversationId, QString peerAddress) +{ + if (chatStatus() != ChatBackendSimpleSource::Online || !isContextReady()) { + emit error(QStringLiteral("Chat not online")); + return; + } + if (conversationId.isEmpty() || peerAddress.isEmpty()) { + emit error(QStringLiteral("Address cannot be empty")); + return; + } + + const LogosResult res = modules().chat_module.add_group_member(conversationId, peerAddress); + if (!res.success) { + const QString reason = res.getError(); + setStatusMessage(QStringLiteral("Failed to add member: ") + reason); + emit error(QStringLiteral("Failed to add member: ") + reason); + return; + } + // The add is committed and the welcome delivered asynchronously, so be + // honest that the peer is not in the group yet. + setStatusMessage(QStringLiteral("Invite sent; peer joins when the group commits")); +} + void ChatBackend::requestMyAddress() { if (chatStatus() != ChatBackendSimpleSource::Online || !isContextReady()) { @@ -271,8 +336,47 @@ void ChatBackend::selectConversation(QString conversationId) if (conversationId == currentConversationId()) return; setCurrentConversationId(conversationId); + syncCurrentConversationMeta(); m_conversationModel->clearUnread(conversationId); showConversationMessages(conversationId); + // Reset the roster on every switch: a group whose roster we can't fetch + // right now (offline) must show empty, not the previous conversation's + // members. refreshMembers then loads the new group's roster when it can, and + // keeps the last-known one across a transient offline of the same group. + // User-driven, so its synchronous read is safe here (not in an event callback). + m_memberModel->clear(); + setMemberCount(0); + refreshMembers(); +} + +void ChatBackend::refreshMembers() +{ + const QString convoId = currentConversationId(); + if (convoId.isEmpty() || !m_conversationModel->isGroupFor(convoId)) { + m_memberModel->clear(); + setMemberCount(0); + return; + } + if (chatStatus() != ChatBackendSimpleSource::Online || !isContextReady()) + return; // a group, but we can't fetch now; keep the last-known roster + + if (m_myAddress.isEmpty()) + m_myAddress = modules().chat_module.get_address(); + + // list_group_members returns [GroupMember], so the typed wrapper is a + // QVariantList (each element a QVariantMap), like the other record lists. + const QVariantList members = modules().chat_module.list_group_members(convoId); + QVector rows; + rows.reserve(members.size()); + for (const QVariant& v : members) { + const QString address = v.toMap().value(QStringLiteral("address")).toString(); + // An empty address is the roster's "no confirmed account" signal; keep + // it — the model renders it as "unknown_account". Only a real account + // address can be self. + rows.append({ address, !address.isEmpty() && address == m_myAddress }); + } + m_memberModel->setMembers(rows); + setMemberCount(static_cast(rows.size())); } // ── event handlers ──────────────────────────────────────────────────────────── @@ -322,16 +426,25 @@ void ChatBackend::applyMessageReceived(const QVariantList& args) if (convoId.isEmpty()) return; const QString content = args.value(1).toString(); const qint64 ts = args.value(2).toLongLong(); + const QString sender = args.value(3).toString(); const QDateTime when = msToDateTime(ts); if (!m_conversationModel->contains(convoId)) { - m_conversationModel->addConversation(convoId, fallbackDisplayName(convoId), when); + // Defensive: ConversationStarted normally lands first with the kind. + // Add it now and backfill the kind by re-reading the list (deferred: + // this runs inside a module event callback, see deferToEventLoop). + m_conversationModel->addConversation(convoId, fallbackDisplayName(convoId), when, false); + deferToEventLoop([this] { rehydrateConversations(); }); } else { m_conversationModel->updateLastActivity(convoId, when); } if (convoId == currentConversationId()) { - m_messageModel->addMessage(QStringLiteral("Peer"), content, when, false); + m_messageModel->addMessage(shortSenderLabel(sender), content, when, false); + // A message from someone not yet on the roster means the group grew; + // refetch (deferred: sync module read from inside an event callback). + if (!sender.isEmpty() && !m_memberModel->contains(sender)) + deferToEventLoop([this] { refreshMembers(); }); } else { m_conversationModel->incrementUnread(convoId); } @@ -348,7 +461,7 @@ void ChatBackend::applyMessageSent(const QVariantList& args) const QDateTime when = msToDateTime(ts); if (!m_conversationModel->contains(convoId)) { - m_conversationModel->addConversation(convoId, fallbackDisplayName(convoId), when); + m_conversationModel->addConversation(convoId, fallbackDisplayName(convoId), when, false); } else { m_conversationModel->updateLastActivity(convoId, when); } @@ -365,29 +478,37 @@ void ChatBackend::applyConversationCreated(const QVariantList& args) if (convoId.isEmpty()) return; const bool isOutgoing = args.value(1).toBool(); const QString peerLabel = args.value(2).toString(); - const QString displayName = fallbackDisplayName(convoId, peerLabel); + const bool isGroup = args.value(3).toString() == QStringLiteral("group"); + const QString displayName = fallbackDisplayName(convoId, peerLabel, isGroup); const QDateTime now = QDateTime::currentDateTime(); if (!m_conversationModel->contains(convoId)) { - m_conversationModel->addConversation(convoId, displayName, now); + m_conversationModel->addConversation(convoId, displayName, now, isGroup); } else { m_conversationModel->updateDisplayName(convoId, displayName); m_conversationModel->updateLastActivity(convoId, now); } - if (isOutgoing && currentConversationId().isEmpty()) - // Deferred: selectConversation makes a synchronous module read and this - // runs inside a module event callback (see deferToEventLoop). + // Open a conversation we just created, so creating a chat or group lands + // the user in it. Deferred: selectConversation makes a synchronous module + // read and this runs inside a module event callback (see deferToEventLoop). + if (isOutgoing) deferToEventLoop([this, convoId] { selectConversation(convoId); }); } void ChatBackend::applyConversationUpdated(const QVariantList& args) { - Q_UNUSED(args); + const QString convoId = args.value(0).toString(); // Cheapest correct option: refresh the whole list (the read is cheap and // conversation counts are small). Deferred — this runs inside a module event // callback (see deferToEventLoop). - deferToEventLoop([this] { rehydrateConversations(); }); + deferToEventLoop([this, convoId] { + rehydrateConversations(); + // A group update (e.g. a member added) may have grown the roster of the + // conversation on screen; refetch it. + if (convoId == currentConversationId() && m_conversationModel->isGroupFor(convoId)) + refreshMembers(); + }); } void ChatBackend::applyConversationDeleted(const QVariantList& args) @@ -398,13 +519,19 @@ void ChatBackend::applyConversationDeleted(const QVariantList& args) m_conversationModel->removeConversation(convoId); if (convoId == currentConversationId()) { setCurrentConversationId(QString()); + syncCurrentConversationMeta(); m_messageModel->clear(); } } -QString ChatBackend::fallbackDisplayName(const QString& convoId, const QString& peerLabel) +QString ChatBackend::fallbackDisplayName(const QString& convoId, const QString& peerLabel, + bool isGroup) +{ + const QString label = peerLabel.isEmpty() ? convoId.left(8) : peerLabel; + return (isGroup ? QStringLiteral("Group ") : QStringLiteral("Chat ")) + label; +} + +QString ChatBackend::shortSenderLabel(const QString& sender) { - if (!peerLabel.isEmpty()) - return QStringLiteral("Chat ") + peerLabel; - return QStringLiteral("Chat ") + convoId.left(8); + return sender.isEmpty() ? QStringLiteral("Peer") : sender.left(8); } diff --git a/src/ChatBackend.h b/src/ChatBackend.h index be2669e..420423f 100644 --- a/src/ChatBackend.h +++ b/src/ChatBackend.h @@ -9,6 +9,7 @@ #include "logos_ui_plugin_context.h" #include "ConversationListModel.h" #include "MessageListModel.h" +#include "MemberListModel.h" class ChatBackend : public ChatBackendSimpleSource, public LogosUiPluginContext @@ -16,6 +17,7 @@ class ChatBackend : public ChatBackendSimpleSource, Q_OBJECT Q_PROPERTY(ConversationListModel* conversationModel READ conversationModel CONSTANT) Q_PROPERTY(MessageListModel* messageModel READ messageModel CONSTANT) + Q_PROPERTY(MemberListModel* memberModel READ memberModel CONSTANT) public: explicit ChatBackend(QObject* parent = nullptr); @@ -23,6 +25,7 @@ class ChatBackend : public ChatBackendSimpleSource, ConversationListModel* conversationModel() const; MessageListModel* messageModel() const; + MemberListModel* memberModel() const; // Fires once the generated plugin glue has wired modules(); the typed // chat_module surface is live, so init + event subscriptions happen here. @@ -30,9 +33,15 @@ class ChatBackend : public ChatBackendSimpleSource, public slots: void createConversation(QString peerAddress) override; + void createGroupConversation() override; + void addGroupMember(QString conversationId, QString peerAddress) override; void requestMyAddress() override; void sendMessage(QString conversationId, QString content) override; void selectConversation(QString conversationId) override; + // Reloads the current group's roster into memberModel; clears it for a + // direct conversation. A synchronous module read, so never call it from + // inside a module event callback without deferToEventLoop. + void refreshMembers() override; private: // Honours $CHAT_MODULE_INSTANCE_PATH, otherwise QStandardPaths::AppDataLocation. @@ -46,6 +55,10 @@ public slots: void initialiseModule(); void subscribeToEvents(); void rehydrateConversations(); + // Pushes the current conversation's group flag and display name as backend + // properties for the QML view to bind — see the .cpp for why the view can't + // read them off the model directly. + void syncCurrentConversationMeta(); void showConversationMessages(const QString& convoId); // Runs `work` on the next event-loop turn. A module read (list_conversations/ @@ -64,12 +77,20 @@ public slots: void applyConversationUpdated(const QVariantList& args); void applyConversationDeleted(const QVariantList& args); - static QString fallbackDisplayName(const QString& convoId, const QString& peerLabel = QString()); + static QString fallbackDisplayName(const QString& convoId, const QString& peerLabel = QString(), + bool isGroup = false); + + // Short display form of a message sender; "Peer" when the sender is empty. + static QString shortSenderLabel(const QString& sender); ConversationListModel* m_conversationModel; MessageListModel* m_messageModel; + MemberListModel* m_memberModel; QString m_instancePath; + // This installation's own address, cached lazily on first roster refresh to + // mark the self entry. + QString m_myAddress; bool m_moduleInitialised = false; // Set once the initial snapshot has loaded; gates the reconnect resync in // applyDeliveryState so it doesn't fire during initial setup. diff --git a/src/ChatBackend.rep b/src/ChatBackend.rep index e2841f0..66d17f6 100644 --- a/src/ChatBackend.rep +++ b/src/ChatBackend.rep @@ -11,11 +11,23 @@ class ChatBackend PROP(QString myIdentity READONLY) PROP(QString statusMessage READONLY) PROP(QString currentConversationId) + // Derived from the current conversation, kept here (not read off the model) + // because the models reach QML as QtRO replicas that don't proxy the model's + // Q_INVOKABLE lookups (isGroupFor/displayNameFor). + PROP(bool currentIsGroup READONLY) + PROP(QString currentDisplayName READONLY) + // The current group's roster size. Exposed as a property (not read off + // memberModel) because the model reaches QML as a QtRO replica whose + // rowCount() a non-view caller can't rely on. + PROP(int memberCount READONLY) SLOT(void createConversation(QString peerAddress)) + SLOT(void createGroupConversation()) + SLOT(void addGroupMember(QString conversationId, QString peerAddress)) SLOT(void requestMyAddress()) SLOT(void sendMessage(QString conversationId, QString content)) SLOT(void selectConversation(QString conversationId)) + SLOT(void refreshMembers()) SIGNAL(addressReady(QString address)) SIGNAL(error(QString message)) diff --git a/src/ConversationListModel.cpp b/src/ConversationListModel.cpp index 97684c7..4574a77 100644 --- a/src/ConversationListModel.cpp +++ b/src/ConversationListModel.cpp @@ -22,6 +22,7 @@ QVariant ConversationListModel::data(const QModelIndex& index, int role) const case DisplayNameRole: return item.displayName; case LastActivityRole: return item.lastActivity; case UnreadCountRole: return item.unreadCount; + case IsGroupRole: return item.isGroup; default: return {}; } } @@ -32,17 +33,18 @@ QHash ConversationListModel::roleNames() const { ConversationIdRole, "conversationId" }, { DisplayNameRole, "displayName" }, { LastActivityRole, "lastActivity" }, - { UnreadCountRole, "unreadCount" } + { UnreadCountRole, "unreadCount" }, + { IsGroupRole, "isGroup" } }; } void ConversationListModel::addConversation(const QString& id, const QString& displayName, - const QDateTime& lastActivity) + const QDateTime& lastActivity, bool isGroup) { if (contains(id)) return; beginInsertRows(QModelIndex(), m_items.size(), m_items.size()); - m_items.append({ id, displayName, lastActivity, 0 }); + m_items.append({ id, displayName, lastActivity, 0, isGroup }); endInsertRows(); } @@ -121,3 +123,9 @@ QString ConversationListModel::displayNameFor(const QString& id) const const int idx = indexOf(id); return idx < 0 ? QString() : m_items.at(idx).displayName; } + +bool ConversationListModel::isGroupFor(const QString& id) const +{ + const int idx = indexOf(id); + return idx >= 0 && m_items.at(idx).isGroup; +} diff --git a/src/ConversationListModel.h b/src/ConversationListModel.h index 4176959..a63db9e 100644 --- a/src/ConversationListModel.h +++ b/src/ConversationListModel.h @@ -11,6 +11,7 @@ struct ConversationItem { QString displayName; QDateTime lastActivity; int unreadCount = 0; + bool isGroup = false; }; class ConversationListModel : public QAbstractListModel @@ -22,7 +23,8 @@ class ConversationListModel : public QAbstractListModel ConversationIdRole = Qt::UserRole + 1, DisplayNameRole, LastActivityRole, - UnreadCountRole + UnreadCountRole, + IsGroupRole }; explicit ConversationListModel(QObject* parent = nullptr); @@ -32,7 +34,7 @@ class ConversationListModel : public QAbstractListModel QHash roleNames() const override; void addConversation(const QString& id, const QString& displayName, - const QDateTime& lastActivity); + const QDateTime& lastActivity, bool isGroup); void updateDisplayName(const QString& id, const QString& displayName); void updateLastActivity(const QString& id, const QDateTime& lastActivity); void incrementUnread(const QString& id); @@ -46,6 +48,9 @@ class ConversationListModel : public QAbstractListModel // Display name for a conversation id, or empty if unknown. Q_INVOKABLE QString displayNameFor(const QString& id) const; + // Whether a conversation is a group; false for a direct or unknown id. + Q_INVOKABLE bool isGroupFor(const QString& id) const; + private: QVector m_items; }; diff --git a/src/MemberListModel.cpp b/src/MemberListModel.cpp new file mode 100644 index 0000000..1ec383b --- /dev/null +++ b/src/MemberListModel.cpp @@ -0,0 +1,63 @@ +#include "MemberListModel.h" + +MemberListModel::MemberListModel(QObject* parent) + : QAbstractListModel(parent) +{ +} + +int MemberListModel::rowCount(const QModelIndex& parent) const +{ + if (parent.isValid()) return 0; + return m_items.size(); +} + +QVariant MemberListModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() >= m_items.size()) + return {}; + + const auto& item = m_items.at(index.row()); + switch (role) { + case AddressRole: return item.address; + // Short identity string, mirroring ChatBackend::fallbackDisplayName's left(8); + // a member with no confirmed account (empty address) shows as unknown_account. + case LabelRole: return item.address.isEmpty() + ? QStringLiteral("unknown_account") + : item.address.left(8); + case IsSelfRole: return item.isSelf; + default: return {}; + } +} + +QHash MemberListModel::roleNames() const +{ + return { + { AddressRole, "address" }, + { LabelRole, "label" }, + { IsSelfRole, "isSelf" } + }; +} + +void MemberListModel::setMembers(const QVector& members) +{ + beginResetModel(); + m_items = members; + endResetModel(); +} + +void MemberListModel::clear() +{ + if (m_items.isEmpty()) return; + beginResetModel(); + m_items.clear(); + endResetModel(); +} + +bool MemberListModel::contains(const QString& address) const +{ + for (const auto& item : m_items) { + if (item.address == address) + return true; + } + return false; +} diff --git a/src/MemberListModel.h b/src/MemberListModel.h new file mode 100644 index 0000000..d38b373 --- /dev/null +++ b/src/MemberListModel.h @@ -0,0 +1,44 @@ +#ifndef MEMBER_LIST_MODEL_H +#define MEMBER_LIST_MODEL_H + +#include +#include +#include + +struct MemberItem { + // The member's verified account address, empty when no account is confirmed + // — used for the tooltip and identity comparisons. + QString address; + // True for this installation's own entry in the roster. + bool isSelf = false; +}; + +// A group's roster, replaced wholesale on each refresh. `label` is the short +// form of `address` (or "unknown_account" when the address is empty), computed +// here so QML renders a consistent identity string. +class MemberListModel : public QAbstractListModel +{ + Q_OBJECT + +public: + enum Roles { + AddressRole = Qt::UserRole + 1, + LabelRole, + IsSelfRole + }; + + explicit MemberListModel(QObject* parent = nullptr); + + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, int role) const override; + QHash roleNames() const override; + + void setMembers(const QVector& members); + void clear(); + bool contains(const QString& address) const; + +private: + QVector m_items; +}; + +#endif diff --git a/src/qml/ChatView.qml b/src/qml/ChatView.qml index 2299f5a..ade7cdc 100644 --- a/src/qml/ChatView.qml +++ b/src/qml/ChatView.qml @@ -31,6 +31,13 @@ Item { ? logos.model("chat_ui", "conversationModel") : null readonly property var msgModel: typeof logos !== "undefined" && logos ? logos.model("chat_ui", "messageModel") : null + readonly property var memberModel: typeof logos !== "undefined" && logos + ? logos.model("chat_ui", "memberModel") : null + + // Whether the open conversation is a group. Derived by the backend: the + // models reach QML as QtRO replicas that don't proxy the model's + // isGroupFor/displayNameFor lookups. + readonly property bool currentIsGroup: backend ? backend.currentIsGroup : false function statusText() { if (!backend) return "No backend" @@ -117,6 +124,29 @@ Item { color: d.isRunning() ? root.accent : root.textTertiary } Item { Layout.fillWidth: true } + Button { + Layout.preferredWidth: 58 + text: "+ group" + enabled: d.isRunning() + font.family: "JetBrains Mono" + font.pixelSize: 12 + + background: Rectangle { + radius: 4 + color: parent.pressed ? "#333" + : parent.hovered ? "#2a2a2a" : root.bgPanel + border.color: parent.enabled ? root.accent : root.border + } + contentItem: Text { + text: parent.text + color: parent.enabled ? root.accent : root.textTertiary + font: parent.font + horizontalAlignment: Text.AlignHCenter + } + // No dialog: the group starts solo and members + // are added from the right pane. + onClicked: { if (d.backend) d.backend.createGroupConversation() } + } Button { Layout.preferredWidth: 50 text: "+ new" @@ -183,7 +213,8 @@ Item { Layout.fillWidth: true spacing: 2 Text { - text: model.displayName || "" + // `#` marks a group row, mirroring the group thread header. + text: (model.isGroup ? "# " : "") + (model.displayName || "") color: root.textPrimary font.family: "JetBrains Mono" font.pixelSize: 13 @@ -296,9 +327,7 @@ Item { anchors.rightMargin: 16 Text { - text: (d.backend && d.convModel) - ? d.convModel.displayNameFor(d.backend.currentConversationId) - : "" + text: d.backend ? d.backend.currentDisplayName : "" color: root.textPrimary font.family: "JetBrains Mono" font.pixelSize: 13 @@ -335,11 +364,25 @@ Item { delegate: Item { width: msgList.width - height: bubble.height + 4 readonly property bool alignRight: model.isMe === true + // Show who sent it above incoming group bubbles; + // 1:1 chats and own messages need no label. + readonly property bool showSender: d.currentIsGroup && !alignRight + height: (showSender ? senderLabel.height + 2 : 0) + bubble.height + 4 + + Text { + id: senderLabel + visible: showSender + x: 16 + text: model.sender || "" + color: root.textSecond + font.family: "JetBrains Mono" + font.pixelSize: 10 + } Rectangle { id: bubble + y: showSender ? senderLabel.height + 2 : 0 x: alignRight ? parent.width - width - 16 : 16 width: Math.min(msgContent.implicitWidth + 24, msgList.width * 0.7) @@ -473,6 +516,28 @@ Item { } } } + + // Vertical separator (members pane, groups only) + Rectangle { + visible: d.currentIsGroup + Layout.preferredWidth: 1 + Layout.fillHeight: true + color: root.border + } + + // ── Right panel: Members (groups only) ─────────────── + MembersPane { + visible: d.currentIsGroup + Layout.preferredWidth: 220 + Layout.fillHeight: true + memberModel: d.memberModel + online: d.isRunning() + onAddMemberRequested: function(address) { + if (d.backend) + d.backend.addGroupMember(d.backend.currentConversationId, address) + } + onRefreshRequested: { if (d.backend) d.backend.refreshMembers() } + } } // ── Status bar ─────────────────────────────────────────── diff --git a/src/qml/MembersPane.qml b/src/qml/MembersPane.qml new file mode 100644 index 0000000..dc3680a --- /dev/null +++ b/src/qml/MembersPane.qml @@ -0,0 +1,214 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +// Standalone group-roster panel: give it a member model and an `online` flag, +// and wire its two signals. It reads no ambient context (no `logos` global, no +// ancestor properties), so it instantiates anywhere. +Rectangle { + id: pane + implicitWidth: 220 + + // The MemberListModel (roles: address, label, isSelf). + required property var memberModel + // Whether the backend is online; gates the add-member controls. + required property bool online + + // Emitted when the user asks to invite `address` into the group. + signal addMemberRequested(string address) + // Emitted when the user asks to reload the roster. + signal refreshRequested() + + readonly property color bgSecondary: "#111111" + readonly property color bgPanel: "#161616" + readonly property color bgPrimary: "#0A0A0A" + readonly property color border: "#2a2a2a" + readonly property color textPrimary: "#FAFAFA" + readonly property color textSecond: "#6B7280" + readonly property color textTertiary:"#4B5563" + readonly property color accent: "#10B981" + readonly property color accentHover: "#34D399" + readonly property color accentPress: "#059669" + + color: bgSecondary + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + // Header + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 48 + color: pane.bgPanel + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + + Text { + text: "members" + color: pane.accent + font.family: "JetBrains Mono" + font.pixelSize: 13 + font.bold: true + } + Item { Layout.fillWidth: true } + Button { + text: "↻" + Layout.preferredWidth: 28 + Layout.preferredHeight: 26 + enabled: pane.online + font.pixelSize: 13 + background: Rectangle { + radius: 4 + color: parent.pressed ? "#333" : parent.hovered ? "#2a2a2a" : pane.bgPanel + border.color: pane.border + } + contentItem: Text { + text: parent.text + color: parent.enabled ? pane.textPrimary : pane.textTertiary + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + onClicked: pane.refreshRequested() + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: pane.border + } + + // Roster + ListView { + id: memberList + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: pane.memberModel + + delegate: Item { + width: memberList.width + height: 40 + + HoverHandler { id: rowHover } + ToolTip.text: model.address + ToolTip.visible: rowHover.hovered && model.address.length > 0 + ToolTip.delay: 400 + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 8 + + Text { + text: model.label || "" + color: pane.textPrimary + font.family: "JetBrains Mono" + font.pixelSize: 12 + elide: Text.ElideRight + Layout.fillWidth: true + } + Text { + visible: model.isSelf === true + text: "you" + color: pane.accent + font.family: "JetBrains Mono" + font.pixelSize: 10 + } + } + + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: 1 + color: pane.border + } + } + + Text { + anchors.centerIn: parent + visible: memberList.count === 0 + text: "No members yet" + color: pane.textTertiary + font.family: "JetBrains Mono" + font.pixelSize: 12 + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: pane.border + } + + // Add-member row + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 52 + color: pane.bgSecondary + + RowLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 8 + + TextField { + id: addrInput + Layout.fillWidth: true + Layout.fillHeight: true + placeholderText: pane.online ? "peer address..." : "offline" + enabled: pane.online + font.family: "JetBrains Mono" + font.pixelSize: 12 + color: pane.textPrimary + background: Rectangle { + radius: 4 + color: pane.bgPanel + border.color: addrInput.activeFocus ? pane.accent : pane.border + } + onAccepted: addBtn.submit() + } + + Button { + id: addBtn + Layout.preferredWidth: 36 + Layout.fillHeight: true + text: "+" + enabled: pane.online && addrInput.text.trim() !== "" + font.family: "JetBrains Mono" + font.pixelSize: 16 + font.bold: true + + function submit() { + var address = addrInput.text.trim() + if (address === "") return + pane.addMemberRequested(address) + addrInput.text = "" + } + + background: Rectangle { + radius: 4 + color: parent.enabled + ? (parent.pressed ? pane.accentPress + : parent.hovered ? pane.accentHover : pane.accent) + : pane.textTertiary + } + contentItem: Text { + text: parent.text + color: "#000000" + font: parent.font + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + onClicked: submit() + } + } + } + } +}