From 98fb2e8c08602fedeca069089795a09adf9b6224 Mon Sep 17 00:00:00 2001
From: wdani <68472201+WilleLX1@users.noreply.github.com>
Date: Fri, 27 Feb 2026 01:18:54 +0100
Subject: [PATCH 1/3] feat: Add v0.3 update plan focusing on user experience
and group workflows
---
V0.2_Update.md | 131 -------------------------------------
V0.2a_Status.md | 82 -----------------------
V0.2b_Status.md | 72 --------------------
V0.3_Update.md | 171 ++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 171 insertions(+), 285 deletions(-)
delete mode 100644 V0.2_Update.md
delete mode 100644 V0.2a_Status.md
delete mode 100644 V0.2b_Status.md
create mode 100644 V0.3_Update.md
diff --git a/V0.2_Update.md b/V0.2_Update.md
deleted file mode 100644
index 447af6d..0000000
--- a/V0.2_Update.md
+++ /dev/null
@@ -1,131 +0,0 @@
-# v0.2: Security/Privacy upgrades that won’t “horribly” hurt performance
-
-Below is the smallest set of changes that massively improves security while keeping the system fast and deployable.
-
-## 1) Add client message signatures (high impact, low cost)
-
-**Goal:** a compromised server can’t impersonate users to other servers (and ideally can’t impersonate locally either).
-
-* Each device has a long-term **signing keypair** (Ed25519).
-* Every outbound message envelope includes:
-
- * `sender_device_pubkey`
- * `signature` over a canonical form of `(conversation_id, sender, recipient, timestamp, client_message_id, ciphertext_hash, …)`
-* Receiver verifies signature **before** accepting.
-
-**Performance:** trivial (Ed25519 signing/verifying is fast).
-**Impact:** huge integrity/auth win immediately.
-
----
-
-## 2) Replace “sealed box per message” with a session key + ratchet (moderate cost)
-
-To call it “secure messaging”, you need at least forward secrecy. The standard answer is:
-
-### X3DH-style handshake + Double Ratchet
-
-* Initial key agreement uses prekeys (server stores public prekeys only)
-* Session uses Double Ratchet for:
-
- * forward secrecy
- * post-compromise recovery
- * message ordering / replay defenses
-
-**Performance impact:** low to moderate (mostly constant-time crypto; the overhead is protocol complexity, not CPU).
-
----
-
-## 3) Multi-device with “kick devices”
-
-You already have a concept of `device_id`. Extend it:
-
-* Account holds list of devices:
-
- * `device_uid` (server-generated stable ID)
- * `device_pub_sign_key`
- * `device_pub_dh_key` (for sessions)
- * status: active / revoked
-* In client settings:
-
- * list devices + last seen
- * “kick” = server marks revoked
-* Protocol:
-
- * messages can target one or multiple devices
- * ratchet sessions are per device-pair (common approach)
-
-**Performance:** depends on “encrypt-to-how-many-devices”. Usually manageable.
-**Privacy:** server still sees devices exist; content remains E2EE.
-
----
-
-## 4) Fix federation TOFU: “open federation” but safer bootstrap
-
-You want open federation, so the upgrade is:
-
-### bind federation trust to onion identity
-
-If Tor is enabled:
-
-* Tie federation identity to onion service identity (or publish a signed binding)
-* On first contact, the onion channel itself becomes the “trust root” (still not perfect against malicious onion takeover, but better than plain TOFU)
-
-**Performance:** none.
-**Security:** prevents silent key swaps and reduces TOFU poisoning risk.
-
----
-
-## 5) Token/auth hardening (privacy + security without latency)
-
-You correctly flagged HS256 risk and replay risk.
-
-For v0.2:
-
-* Move access tokens to **asymmetric signing** (EdDSA/RS256)
-* Bind tokens to device:
-
- * include `device_uid` in JWT claims
- * require match at server on every request
-* Short access token lifetime (e.g., 5–10 min)
-* Refresh token rotation stays (good)
-* Consider DPoP-style proof or per-request nonce if you want to harden replay further (optional)
-
-**Performance:** negligible.
-
----
-
-## 6) End-to-end delivery integrity (detect drops/reordering)
-
-You said you want privacy and trust minimization; but “server can drop silently” is a real UX/security problem.
-
-Add:
-
-* Per-conversation monotonic counters or hash-chains:
-
- * each message includes `prev_hash` (hash of previous accepted message)
- * receiver detects gaps/tampering
-* “Missing message” UI that doesn’t reveal plaintext, just indicates integrity issue
-
-**Performance:** tiny (hashing).
-
----
-
-## 7) Voice: secure without “a bunch of latency”
-
-Your current voice (PCM over WS, no E2EE) is the biggest privacy hole.
-
-The lowest-latency, real-world solution is:
-
-### Use WebRTC for media, keep your federation for signaling
-
-* Signaling: your existing WS/federation routes
-* Media: WebRTC SRTP (low latency, jitter buffers, NAT traversal)
-* For true E2EE beyond SRTP termination concerns: use WebRTC Insertable Streams (where available) or an application-layer frame encryption
-
-This is how modern low-latency secure voice/video is typically done.
-
-If you refuse WebRTC and keep WS streaming:
-
-* You can still E2EE frames with AEAD + sequence numbers,
-* but you’ll end up re-implementing jitter buffering + congestion control badly.
- So: **WebRTC is the performance-friendly choice.**
diff --git a/V0.2a_Status.md b/V0.2a_Status.md
deleted file mode 100644
index 28bde97..0000000
--- a/V0.2a_Status.md
+++ /dev/null
@@ -1,82 +0,0 @@
-# Blackwire v0.2a Status (as of 2026-02-23)
-
-This file tracks what has been implemented from the v0.2 milestones and what is deferred to v0.2b.
-
-## v0.2a Milestones
-
-### 1) Client message signatures + sender-key pinning
-Status: Done
-
-- Added detached signing/verification support in client crypto service.
-- `/api/v2/messages/send` now carries signed per-device envelopes.
-- Client verifies sender signatures before accept/decrypt.
-- TOFU pinning map is persisted by `sender_device_uid`; key change triggers hard integrity warning.
-
-### 2) True multi-device accounts + revoke (kick)
-Status: Done
-
-- Server supports multi-device listing, active/revoked status, and revoke endpoint in `/api/v2`.
-- Revoke flow invalidates device sessions and enforces immediate cutoff behavior.
-- Client send path resolves all active recipient devices and mirrors to own active devices.
-- Settings UI now shows device inventory and supports kick/revoke with confirmation.
-
-### 3) Federation trust bound to Tor onion identity
-Status: Done (v0.2a scope)
-
-- v2 federation well-known includes binding metadata/signing key.
-- Peer onboarding validates signing key against onion-derived identity binding (Tor mode path).
-- Existing signed/nonce federation protections are preserved.
-
-### 4) Asymmetric, device-bound JWT sessions (bootstrap -> bind)
-Status: Done
-
-- Added v2 bootstrap auth flow (`register/login` -> bootstrap token).
-- Added device registration and bind-device exchange for device-bound token issuance.
-- Added EdDSA token subsystem (`sub`, `did`, `type`, `iat`, `exp`, `jti`) with refresh rotation.
-- v2 auth dependencies enforce active device ownership on protected routes.
-
-### 5) End-to-end delivery integrity chain + integrity UI signaling
-Status: Done
-
-- v2 message events store sender chain fields (`sender_prev_hash`, `sender_chain_hash`).
-- Client computes/signs canonical message material and validates chain continuity.
-- Integrity mismatches (key change/signature/chain gap) are surfaced as warnings.
-- UI wiring now includes integrity warning banner + settings integrity status area.
-
-### 6) Parallel `/api/v2` with `/api/v1` compatibility
-Status: Done
-
-- Added full `/api/v2` router set and WS endpoint without removing `/api/v1`.
-- Added v2 message tables and flow without destructive rewrite of v1 tables.
-- v1 compatibility path remains in place.
-
-### 7) Tests for v2 + v1 regression retention
-Status: Mostly done (server complete, client pending local execution)
-
-- Added server integration coverage for v2 security core behavior.
-- Existing v1 tests are still passing in current run.
-- Client tests were expanded (crypto + serialization updates), but full local execution is still blocked in this environment because `cmake` is unavailable.
-
-## What is Left for v0.2b
-
-### A) X3DH + Double Ratchet migration
-Status: Not started (deferred by plan)
-
-Planned next:
-- Prekey publishing/consumption model.
-- Session establishment via X3DH.
-- Double Ratchet per device-pair with replay/ordering handling.
-- Migration path from sealed-box-only v0.2a envelopes.
-
-### B) WebRTC media migration
-Status: Not started (deferred by plan)
-
-Planned next:
-- Keep signaling on existing control plane.
-- Move voice media off raw WS PCM to WebRTC media path.
-- Add media-plane E2EE strategy compatible with federation design.
-
-## Notes
-
-- v0.2a core security milestones are implemented in code and API surface.
-- Remaining work to fully close v0.2a is operational validation on a machine with CMake/Qt test toolchain available.
diff --git a/V0.2b_Status.md b/V0.2b_Status.md
deleted file mode 100644
index 2e1e587..0000000
--- a/V0.2b_Status.md
+++ /dev/null
@@ -1,72 +0,0 @@
-# Blackwire v0.2b Status
-
-## Completed in this implementation pass
-
-### v0.2b1 (X3DH/Double Ratchet rollout scaffolding + dual-stack contracts)
-- Added ratchet/prekey schema migration:
- - `server/migrations/versions/20260223_0006_v2_ratchet_core.py`
- - New tables: `device_signed_prekeys`, `device_one_time_prekeys`, `ratchet_sessions`, `ratchet_skipped_keys`
- - Extended `message_events` with indexed `encryption_mode`
-- Added v2 prekey contracts and service:
- - `POST /api/v2/keys/prekeys/upload`
- - `GET /api/v2/users/resolve-prekeys`
- - `GET /api/v2/federation/users/{username}/prekeys`
-- Extended v2 device resolution contract:
- - Per-device `supported_message_modes` in local and federation device responses
-- Extended v2 messaging contract:
- - `encryption_mode` accepted and persisted (`sealedbox_v0_2a`, `ratchet_v0_2b1`)
- - Ratchet envelope fields supported with typed schema validation: `ratchet_header`, optional `ratchet_init`
- - Policy enforcement:
- - `BLACKWIRE_ENABLE_RATCHET_V2B1`
- - `BLACKWIRE_RATCHET_REQUIRE_FOR_LOCAL`
- - `BLACKWIRE_RATCHET_REQUIRE_FOR_FEDERATION`
- - Mode-specific metrics counters emitted
- - Additional ratchet metrics wired:
- - `ratchet.session.established`
- - `ratchet.prekey.opk.exhausted`
-- Client wiring for dual-stack behavior:
- - DTO/API support for prekey upload/resolve and `encryption_mode`
- - Send path negotiates ratchet mode by capability + prekey availability, else sealed-box fallback
- - Prekey upload on device setup/re-bind
-- Full cryptographic implementation of X3DH + Double Ratchet state transitions:
- - Real root/send/recv chain key evolution
- - Skipped-key material encryption lifecycle
- - Post-compromise recovery semantics
-- End-to-end ratchet decrypt path replacing sealed-box fallback behavior
-
-
-### v0.2b2 (WebRTC signaling migration scaffolding)
-- Added config gating:
- - `BLACKWIRE_ENABLE_WEBRTC_V2B2`
- - `BLACKWIRE_WEBRTC_ICE_SERVERS_JSON` startup validation
- - `BLACKWIRE_ENABLE_LEGACY_CALL_AUDIO_WS`
-- Added v2 WS signaling events:
- - `call.webrtc.offer`
- - `call.webrtc.answer`
- - `call.webrtc.ice`
-- Added v2 federation relay endpoints:
- - `POST /api/v2/federation/calls/webrtc-offer`
- - `POST /api/v2/federation/calls/webrtc-answer`
- - `POST /api/v2/federation/calls/webrtc-ice`
-- Added call schema fields for forward compatibility:
- - `call_schema_version`, `call_mode`, `max_participants`
-- Added WebRTC metadata propagation in `call.accepted` payloads:
- - `call_schema_version`, `call_mode`, `max_participants`, `ice_servers`
-- Client WS and controller wiring:
- - New DTO/events/methods for WebRTC signaling
- - UI state updates for signaling progress (`reason` changes/diagnostics), including ICE metadata diagnostics
-- Real `libwebrtc` media engine integration in Qt client
-- Actual SDP/ICE generation, candidate gathering, and RTP media track transport
-- TURN/STUN runtime integration and media-failure recovery logic
-- Decommissioning WS PCM transport after stabilization window
-
-
-## Tests added and executed
-- Added integration tests:
- - `server/tests/integration/test_v2_prekey_upload_and_resolve` (in `test_v2_security_core.py`)
- - `server/tests/integration/test_v2_ratchet_send_rejected_when_feature_disabled` (in `test_v2_security_core.py`)
- - `server/tests/integration/test_v2_webrtc_signaling.py`
-- Executed and passing:
- - `server/tests/integration/test_v2_security_core.py`
- - `server/tests/integration/test_v2_webrtc_signaling.py`
- - Selected legacy voice tests passed (`test_offer_accept_audio_end`, `test_busy_and_invalid_audio_errors`)
diff --git a/V0.3_Update.md b/V0.3_Update.md
new file mode 100644
index 0000000..733a17a
--- /dev/null
+++ b/V0.3_Update.md
@@ -0,0 +1,171 @@
+# Blackwire v0.3 Update Plan (as of February 27, 2026)
+
+## Why v0.3
+
+v0.2 delivered major security and protocol foundations across v2 routes, ratchet scaffolding, and WebRTC migration paths. v0.3 is focused on turning those foundations into a stronger day-to-day user experience, especially around group workflows and user comfort, while preserving reliability and security guarantees.
+
+## Release Goals
+
+- Deliver a decision-complete phased roadmap for v0.3 (`0.3a`, `0.3b`, `0.3c`) with explicit exit criteria.
+- Prioritize UX, group experience, and user comfort without weakening security posture.
+- Cover both server and Qt client deliverables.
+- Keep `/api/v1` functional throughout v0.3 with explicit deprecation messaging; no removal during v0.3.
+
+## v0.3a - Group UX GA + Comfort Baseline
+
+Goal: make group chat and group call flows production-ready and predictable.
+
+### Server deliverables
+
+- Enable and harden `enable_group_dm_v2c` and `enable_group_call_v2c` paths for staged rollout.
+- Close correctness gaps in invite/accept/leave/remove/ownership-transfer behavior.
+- Finalize group call state handling with federation consistency:
+- Ringing lifecycle
+- Join / reject flows
+- Leave / end flows
+- Disconnect recovery
+
+### Client deliverables
+
+- Polish group creation, invite, rename, leave, and join flows.
+- Improve group-related error messaging for clear user actions.
+- Improve call UX diagnostics for busy/offline/invalid-state paths.
+- Improve reconnect behavior for active and recently ended call sessions.
+
+### Comfort deliverables
+
+- Improve presence-driven UI clarity for conversation list state and call affordances.
+
+### Exit criteria
+
+- End-to-end group flows pass across local and federated setups.
+- No regressions in existing 1:1 messaging and 1:1 calling flows.
+
+## v0.3b - New Features + Quality (Typing, Read State, Attachments UX)
+
+Goal: add high-impact comfort features for daily use and make behavior deterministic.
+
+Feature set locked for v0.3b:
+
+- Typing indicators
+- Read-state improvements
+- Attachment UX quality improvements
+
+### Server deliverables
+
+- Add typing/read-state contracts and event paths, including rate limits and federation-safe behavior.
+- Improve attachment semantics for client comfort:
+- Retryable failure paths
+- Payload-limit feedback
+- Queue-pressure signaling
+
+### Client deliverables
+
+- Render typing and read-state signals clearly in conversation UI.
+- Add attachment send/receive status states:
+- Queued
+- Sending
+- Failed
+- Retry
+- Success
+- Surface actionable failure reasons in UX (not generic errors).
+
+### Exit criteria
+
+- Typing/read/attachment behavior is deterministic under reconnects, multi-device fanout, and federation relay.
+
+## v0.3c - Stabilization + Deprecation Readiness
+
+Goal: quality hardening, performance tuning, and migration confidence.
+
+### Stabilization scope
+
+- Defect burn-down for group, call, typing, read-state, and attachment flows.
+
+### Reliability scope
+
+- Tune queue-pressure behavior and retry surfaces.
+- Harden reconnect semantics and eventual consistency expectations.
+- Improve delivery observability for production diagnosis.
+
+### Compatibility scope
+
+- Publish explicit `/api/v1` deprecation guidance.
+- Publish `/api/v2` migration playbook.
+
+### Policy
+
+- `/api/v1` remains functional in v0.3.
+- `/api/v1` removal is deferred beyond v0.3.
+
+### Exit criteria
+
+- Release checklist complete.
+- Regression suite green.
+- Migration docs validated against real client paths.
+
+## Public API and Contract Changes
+
+### Existing v2 group endpoints targeted for GA
+
+- `POST /api/v2/conversations/group`
+- `POST /api/v2/conversations/{conversation_id}/members/invite`
+- `POST /api/v2/conversations/{conversation_id}/invites/accept`
+- `POST /api/v2/conversations/{conversation_id}/leave`
+- `POST /api/v2/conversations/{conversation_id}/rename`
+
+### Planned typing and read-state contracts
+
+- REST: `POST /api/v2/conversations/{conversation_id}/typing`
+- REST: `POST /api/v2/conversations/{conversation_id}/read`
+- WS events: `conversation.typing`, `conversation.read`
+
+### Planned attachment UX contract clarifications
+
+- Message status model extensions for retry and failure-reason propagation.
+- Documented max payload and inline policy behavior for clients.
+
+### Deprecation and compatibility state
+
+- `/api/v1` supported in v0.3, explicitly marked deprecated.
+- `/api/v2` treated as primary client integration surface.
+
+## Testing and Exit Criteria
+
+The following scenarios must be explicitly covered:
+
+- Group lifecycle integration: create, invite, accept, rename, leave, remove, ownership transfer.
+- Group call lifecycle integration: offer/join/reject/leave/end across local and federated peers.
+- Multi-device behavior: typing/read/attachment state coherence across sender and recipient devices.
+- Reconnect resilience: websocket reconnect with pending events and no duplicate-visible artifacts.
+- Attachment UX: oversized payload rejection, rate-limit rejection, queue-pressure rejection, user-visible retry path.
+- Compatibility regression: v1 routes continue to work while v2-first client flows stay green.
+- Security regression: no bypass of device-bound auth checks and no integrity-warning regressions.
+
+## Risks and Mitigations
+
+- Risk: scope spread across group, UX, and quality.
+- Mitigation: strict phase gates with must-pass exit criteria per phase.
+
+- Risk: dual-stack complexity (`/api/v1` + `/api/v2`).
+- Mitigation: explicit deprecation messaging and migration checklist in v0.3c.
+
+- Risk: federation edge-case drift.
+- Mitigation: mandatory federated integration scenarios in each phase test matrix.
+
+## Non-Goals for v0.3
+
+- Removing `/api/v1` in this release.
+- Large protocol redesign beyond ratchet/WebRTC paths already established in v0.2.
+- New client platforms beyond existing server + Qt client scope.
+
+## Assumptions and Defaults
+
+- Audience for `V0.3_Update.md`: engineering-facing roadmap that remains externally readable.
+- All phase details are planned work, not completed work.
+- Existing v0.2b foundations (ratchet/WebRTC scaffolding) are baseline, not re-proposed.
+- "New features and qualities" preference is implemented as:
+- Group GA first (`0.3a`)
+- Typing/read/attachment UX next (`0.3b`)
+- Stabilization and deprecation readiness last (`0.3c`)
+- Timeline is phase-based (`0.3a/0.3b/0.3c`) rather than calendar-date commitments.
From 3dbc85deca1c71931aad3a11cc8e46ad982b3a6b Mon Sep 17 00:00:00 2001
From: wdani <68472201+WilleLX1@users.noreply.github.com>
Date: Wed, 4 Mar 2026 16:19:01 +0100
Subject: [PATCH 2/3] feat: add v2 system version endpoint and conversation
read cursor model
- Implemented a new FastAPI endpoint for retrieving system version information at /api/v2/system/version.
- Created ConversationReadCursor model to track the last read message for users in conversations.
- Added version_info service to resolve server version details including git commit and build timestamp.
- Introduced read_state_service_v2 to manage read cursor functionality and relay read events.
- Developed typing_service_v2 to handle typing indicators and relay typing events.
- Added integration tests for typing and read cursor functionalities, ensuring proper event fanout and access control.
- Created database migration for conversation_read_cursors table.
---
README.md | 74 +-
V0.3_Update.md | 244 +++----
client-cpp-gui/CMakeLists.txt | 7 +-
.../include/blackwire/api/qt_api_client.hpp | 21 +
.../controller/application_controller.hpp | 29 +
.../blackwire/interfaces/api_client.hpp | 21 +
.../blackwire/interfaces/ws_client.hpp | 4 +
.../include/blackwire/models/dto.hpp | 192 ++++-
.../include/blackwire/models/view_models.hpp | 9 +
.../blackwire/storage/client_state.hpp | 43 +-
.../include/blackwire/ui/chat_widget.hpp | 9 +-
.../include/blackwire/ui/settings_dialog.hpp | 7 +
.../include/blackwire/ws/qt_ws_client.hpp | 4 +
client-cpp-gui/src/api/qt_api_client.cpp | 51 ++
.../src/controller/application_controller.cpp | 675 +++++++++++++++++-
client-cpp-gui/src/smoke/smoke_runner.cpp | 2 +
client-cpp-gui/src/ui/chat_widget.cpp | 614 +++++++++++++---
client-cpp-gui/src/ui/login_widget.cpp | 96 ++-
client-cpp-gui/src/ui/main_window.cpp | 30 +
client-cpp-gui/src/ui/settings_dialog.cpp | 55 ++
client-cpp-gui/src/ui/theme.cpp | 577 ++++++++++-----
client-cpp-gui/src/ui/theme.cpp.bak | 518 ++++++++++++++
client-cpp-gui/src/util/message_view.cpp | 28 +-
client-cpp-gui/src/ws/qt_ws_client.cpp | 20 +
.../tests/test_envelope_serialization.cpp | 48 ++
client-cpp-gui/tests/test_message_view.cpp | 63 ++
.../tests/test_state_store_compat.cpp | 68 ++
server/app/api_v2/__init__.py | 4 +-
server/app/api_v2/conversations.py | 73 ++
server/app/api_v2/federation.py | 26 +
server/app/api_v2/system.py | 14 +
server/app/api_v2/ws.py | 5 +-
server/app/config.py | 15 +-
server/app/main.py | 6 +
server/app/models/__init__.py | 2 +
server/app/models/conversation_read_cursor.py | 29 +
server/app/schemas/v2_conversation.py | 35 +
server/app/schemas/v2_federation.py | 18 +
server/app/schemas/v2_system.py | 8 +
server/app/services/read_state_service_v2.py | 306 ++++++++
server/app/services/typing_service_v2.py | 224 ++++++
server/app/services/version_info.py | 43 ++
...20260301_0009_conversation_read_cursors.py | 83 +++
.../test_v2_typing_read_version.py | 342 +++++++++
spec/api.md | 286 +++++---
45 files changed, 4419 insertions(+), 609 deletions(-)
create mode 100644 client-cpp-gui/src/ui/theme.cpp.bak
create mode 100644 server/app/api_v2/system.py
create mode 100644 server/app/models/conversation_read_cursor.py
create mode 100644 server/app/schemas/v2_system.py
create mode 100644 server/app/services/read_state_service_v2.py
create mode 100644 server/app/services/typing_service_v2.py
create mode 100644 server/app/services/version_info.py
create mode 100644 server/migrations/versions/20260301_0009_conversation_read_cursors.py
create mode 100644 server/tests/integration/test_v2_typing_read_version.py
diff --git a/README.md b/README.md
index b666c43..ca925d6 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Blackwire
-Blackwire v0.1 is a security-first encrypted messaging platform with Tor-native federation.
+Blackwire v0.3 (Wave 1) is a security-first encrypted messaging platform with Tor-native federation.
## What is implemented
@@ -9,7 +9,7 @@ Blackwire v0.1 is a security-first encrypted messaging platform with Tor-native
- Ciphertext-only message storage and forwarding.
- Password auth with JWT access tokens and rotating refresh tokens.
- Single active device model per account.
-- Direct 1:1 conversations.
+- Direct 1:1 conversations and group conversations.
- Local + federated (`username@onion`) DM routing.
- Home-server-only client routing (client talks only to its configured home server URL).
- Canonical user identity returned by server as `user_address` (`username@onion`).
@@ -17,7 +17,21 @@ Blackwire v0.1 is a security-first encrypted messaging platform with Tor-native
- 7-day TTL expiry for undelivered queue entries.
- Signed server-to-server federation requests with TOFU key pinning.
- Federated message relay with durable outbox retry.
-- Federated voice call signaling and audio relay.
+- Federated voice call signaling (direct and group call flows).
+- Typing indicator contracts/events (`/api/v2`) with federation relay.
+- Conversation read cursor contracts/events (`/api/v2`) with federation relay.
+- Server version endpoint (`GET /api/v2/system/version`).
+- Qt client markdown message rendering with raw HTML disabled.
+- Qt client inline image rendering and click-to-play inline video dialog.
+- Qt client attachment lifecycle UX (`queued`, `sending`, `success`, `failed`, retry).
+- Qt client settings display of client/server version in `Settings -> My Account`.
+- Qt client encrypted message cache with user privacy control toggle (`Settings → Data & Privacy`).
+- Qt client external link click confirmation dialog for markdown links.
+- Qt client Discord-style dark theme with flat design and inline avatars.
+- Qt client friends list filtering (excludes group DMs from contacts sidebar).
+- Qt client call message cache formatting (CALL icon/styling persists after conversation switches).
+- Qt client call target stability (peer name from actual call target, not selected conversation).
+- JSON deserialization hardening for state persistence (null-safe field loading).
- Optional Redis-backed rate-limiter mode (core works without Redis).
- Python reference client (`tools/reference_client`) with libsodium sealed-box encrypt/decrypt flow.
- Unit + integration tests under `server/tests`.
@@ -174,37 +188,33 @@ Run client smoke E2E (requires running server):
## API surface
-Implemented API prefix: `/api/v1`
-
-- `POST /auth/register`
-- `POST /auth/login`
-- `POST /auth/refresh`
-- `POST /auth/logout`
-- `GET /me`
-- `POST /devices/register`
-- `GET /users/{username}/device`
-- `GET /users/resolve-device?peer_address=...`
-- `POST /conversations/dm`
-- `GET /conversations`
-- `GET /conversations/{conversation_id}/messages`
-- `POST /messages/send`
-- `GET /federation/well-known`
-- `GET /federation/users/{username}/device`
-- `POST /federation/messages/relay`
-- `POST /federation/calls/offer`
-- `POST /federation/calls/accept`
-- `POST /federation/calls/reject`
-- `POST /federation/calls/end`
-- `POST /federation/calls/audio`
-- `GET /health/live`
-- `GET /health/ready`
-- `GET /api/v1/metrics`
-- `GET /api/v1/ws` (requires `Authorization: Bearer ` during websocket handshake)
-
-## Current v0.1 constraints
+Primary API prefix: `/api/v2`
+Legacy compatibility API prefix: `/api/v1` (still supported in this wave)
+
+Key `/api/v2` routes include:
+
+- Auth and device lifecycle (`/auth/*`, `/devices/*`, `/users/*`, `/keys/*`)
+- Presence (`POST /presence/set`, `POST /presence/resolve`)
+- Conversations (`/conversations/dm`, `/conversations/group`, `/conversations/{id}/members/*`, `/conversations/{id}/messages`)
+- Typing/read state:
+ - `POST /conversations/{conversation_id}/typing`
+ - `POST /conversations/{conversation_id}/read`
+ - `GET /conversations/{conversation_id}/read`
+- Messaging (`POST /messages/send`)
+- Federation (`/federation/*`) including:
+ - `POST /federation/conversations/typing`
+ - `POST /federation/conversations/read`
+- System:
+ - `GET /system/version`
+- WebSocket:
+ - `GET /api/v2/ws` (requires bearer auth during websocket handshake)
+
+Legacy `/api/v1` routes remain available for compatibility (see `spec/api.md`).
+
+## Current constraints
- One active device per user.
-- Sealed-box message encryption (no Noise session ratchet in v0.1).
+- Sealed-box message encryption baseline remains; ratchet scaffolding exists but full protocol migration is phased.
## License
diff --git a/V0.3_Update.md b/V0.3_Update.md
index 733a17a..237a20a 100644
--- a/V0.3_Update.md
+++ b/V0.3_Update.md
@@ -1,171 +1,175 @@
-# Blackwire v0.3 Update Plan (as of February 27, 2026)
+# Blackwire v0.3 Update (Wave 1 Delivered: `0.3a + 0.3b`)
-## Why v0.3
+## Scope
-v0.2 delivered major security and protocol foundations across v2 routes, ratchet scaffolding, and WebRTC migration paths. v0.3 is focused on turning those foundations into a stronger day-to-day user experience, especially around group workflows and user comfort, while preserving reliability and security guarantees.
+Wave 1 delivers:
-## Release Goals
+1. `0.3a`: group DM and group call `v2c` moved to production-default, with regression hardening.
+2. `0.3b`: typing indicators, read cursor contracts, markdown message rendering, inline media rendering for images/videos, attachment send lifecycle UX, and server/client version visibility in `Settings -> My Account`.
+3. `/api/v1` remains functional and unchanged in this wave.
-- Deliver a decision-complete phased roadmap for v0.3 (`0.3a`, `0.3b`, `0.3c`) with explicit exit criteria.
-- Prioritize UX, group experience, and user comfort without weakening security posture.
-- Cover both server and Qt client deliverables.
-- Keep `/api/v1` functional throughout v0.3 with explicit deprecation messaging; no removal during v0.3.
+`0.3c` stabilization and deprecation hardening remains a follow-up wave.
-## v0.3a - Group UX GA + Comfort Baseline
+## Locked Decisions Applied
-Goal: make group chat and group call flows production-ready and predictable.
+1. Delivery order: `0.3a` then `0.3b`.
+2. Markdown policy: CommonMark-compatible rendering with raw HTML disabled.
+3. Media policy: images inline; videos inline with explicit click-to-play (no autoplay).
+4. Read model: persistent conversation-level cursor per `(conversation_id, user_id)`.
+5. Compatibility: keep `/api/v1` operational during this wave.
-### Server deliverables
+## Server Changes
-- Enable and harden `enable_group_dm_v2c` and `enable_group_call_v2c` paths for staged rollout.
-- Close correctness gaps in invite/accept/leave/remove/ownership-transfer behavior.
-- Finalize group call state handling with federation consistency:
-- Ringing lifecycle
-- Join / reject flows
-- Leave / end flows
-- Disconnect recovery
+### Defaults and Flags
-### Client deliverables
+- `enable_group_dm_v2c` default: `true`
+- `enable_group_call_v2c` default: `true`
+- Added:
+ - `enable_typing_v03b` (default `true`)
+ - `enable_read_cursor_v03b` (default `true`)
+ - `typing_indicator_ttl_seconds`
+ - `typing_event_rate_per_minute`
+ - `read_cursor_write_rate_per_minute`
-- Polish group creation, invite, rename, leave, and join flows.
-- Improve group-related error messaging for clear user actions.
-- Improve call UX diagnostics for busy/offline/invalid-state paths.
-- Improve reconnect behavior for active and recently ended call sessions.
+### New API Contracts (`/api/v2`)
-### Comfort deliverables
+1. `POST /api/v2/conversations/{conversation_id}/typing`
+ - Request: `{"state":"on|off","client_ts_ms":}`
+ - Response: `{"ok":true,"expires_in_ms":}`
+2. `POST /api/v2/conversations/{conversation_id}/read`
+ - Request: `{"last_read_message_id":"...","last_read_sent_at_ms":}`
+ - Response: `{"conversation_id":"...","reader_user_address":"...","last_read_message_id":"...","last_read_sent_at_ms":,"updated_at":"ISO8601"}`
+3. `GET /api/v2/conversations/{conversation_id}/read`
+ - Response: `{"conversation_id":"...","cursors":[...]}`
+4. `GET /api/v2/system/version`
+ - Response: `{"server_version":"...","api_version":"v2","git_commit":"...","build_timestamp":"..."}`
-- Improve presence-driven UI clarity for conversation list state and call affordances.
+### New Federation Relay Endpoints (`/api/v2/federation`)
-### Exit criteria
+1. `POST /api/v2/federation/conversations/typing`
+2. `POST /api/v2/federation/conversations/read`
-- End-to-end group flows pass across local and federated setups.
-- No regressions in existing 1:1 messaging and 1:1 calling flows.
+Both use existing signed federation write authentication.
-## v0.3b - New Features + Quality (Typing, Read State, Attachments UX)
+### New WS Event Fanout
-Goal: add high-impact comfort features for daily use and make behavior deterministic.
+1. `conversation.typing`
+2. `conversation.read`
-Feature set locked for v0.3b:
+Client-sent typing/read over websocket remains unsupported by design; writes are REST-only and fanout is websocket.
-- Typing indicators
-- Read-state improvements
-- Attachment UX quality improvements
+### Data Model and Migration
-### Server deliverables
+- New table: `conversation_read_cursors`
+- Unique key: `(conversation_id, user_id)`
+- Indexed for fast conversation cursor reads and recency ordering.
-- Add typing/read-state contracts and event paths, including rate limits and federation-safe behavior.
-- Improve attachment semantics for client comfort:
-- Retryable failure paths
-- Payload-limit feedback
-- Queue-pressure signaling
+### Behavior Rules Implemented
-### Client deliverables
+1. Typing is ephemeral (not persisted), sender-excluded, client-expiring by TTL.
+2. Read cursor is persistent and monotonic; stale updates are no-op.
+3. Read cursor rejects message/conversation mismatches.
+4. Read updates fan out locally and over federation relay.
-- Render typing and read-state signals clearly in conversation UI.
-- Add attachment send/receive status states:
-- Queued
-- Sending
-- Failed
-- Retry
-- Success
-- Surface actionable failure reasons in UX (not generic errors).
+## Client Changes
-### Exit criteria
+### Messaging and Rendering
-- Typing/read/attachment behavior is deterministic under reconnects, multi-device fanout, and federation relay.
+1. Markdown-formatted message rendering in chat.
+2. Raw HTML/script content not executed/rendered as HTML.
+3. Attachment envelope compatibility now supports optional `mime_type` and `media_kind` fields in `bwfile://v1` payloads.
+4. Inline image rendering in-thread.
+5. Inline video rendering with explicit user click-to-play dialog.
-## v0.3c - Stabilization + Deprecation Readiness
+### Realtime UX
-Goal: quality hardening, performance tuning, and migration confidence.
+1. Handles `conversation.typing` websocket events.
+2. Handles `conversation.read` websocket events.
+3. Publishes typing state via REST with local debounce/expiry behavior.
+4. Publishes and merges read cursor state per conversation.
-### Stabilization scope
+### Attachment Send Lifecycle
-- Defect burn-down for group, call, typing, read-state, and attachment flows.
+Outgoing attachment states in local UX:
-### Reliability scope
+1. `queued`
+2. `sending`
+3. `success`
+4. `failed`
+5. retry action transitions failed attachment back to sending.
-- Tune queue-pressure behavior and retry surfaces.
-- Harden reconnect semantics and eventual consistency expectations.
-- Improve delivery observability for production diagnosis.
+### Settings: My Account
-### Compatibility scope
+`Settings -> My Account` now shows:
-- Publish explicit `/api/v1` deprecation guidance.
-- Publish `/api/v2` migration playbook.
+1. Client version
+2. Server version
-### Policy
+Version display is informational and does not gate auth/session flows.
-- `/api/v1` remains functional in v0.3.
-- `/api/v1` removal is deferred beyond v0.3.
+## Validation Status
-### Exit criteria
+### Server tests
-- Release checklist complete.
-- Regression suite green.
-- Migration docs validated against real client paths.
+- New integration suite for typing/read/version: passing.
+- Existing v2 group/security suites: passing.
+- Multiple legacy `/api/v1` and compatibility integration suites: passing.
-## Public API and Contract Changes
+### Client tests
-### Existing v2 group endpoints targeted for GA
+Added/extended unit coverage for:
-- `POST /api/v2/conversations/group`
-- `POST /api/v2/conversations/{conversation_id}/members/invite`
-- `POST /api/v2/conversations/{conversation_id}/invites/accept`
-- `POST /api/v2/conversations/{conversation_id}/leave`
-- `POST /api/v2/conversations/{conversation_id}/rename`
+1. render mode selection (`attachment` vs `markdown`)
+2. attachment status/retryability mapping in message view models
+3. typing/read/version DTO and WS event parsing
+4. local state compatibility for new attachment metadata fields
-### Planned typing and read-state contracts
+Note: GUI build/test execution requires local CMake/Qt toolchain availability in the environment.
-- REST: `POST /api/v2/conversations/{conversation_id}/typing`
-- REST: `POST /api/v2/conversations/{conversation_id}/read`
-- WS events: `conversation.typing`, `conversation.read`
+## Post-Wave 1 Enhancements (This Session)
-### Planned attachment UX contract clarifications
+### UI & UX Polish
-- Message status model extensions for retry and failure-reason propagation.
-- Documented max payload and inline policy behavior for clients.
+1. **Discord-Style Theme Redesign**
+ - Dark theme with Discord exact colors (`#313338` main, `#2b2d31` sidebar, `#1e1f22` inputs)
+ - Flat message rendering (no bubble borders), 40px round avatars
+ - 240-320px sidebar with "DIRECT MESSAGES" section, "Find or start a conversation" search
+ - Fixed header bar with `@` channel icon, vertical divider
+ - Compose bar: Discord `#383a40` rounded, 44px input, "Message #channel" placeholder
+ - Identity panel: horizontal layout with transparent copy/settings buttons
+ - Presence colors: green `#23a55a`, yellow `#f0b232`, red `#f23f43`
+ - Thin 8px scrollbars, flat borderless buttons (3px radius)
-### Deprecation and compatibility state
+2. **Security: Link Click Confirmation**
+ - External links in markdown now prompt: "Do you want to open this link?\n\n{url}"
+ - Yes/No dialog with No as default — prevents accidental phishing link clicks
+ - Similar to Discord's link safety UX
-- `/api/v1` supported in v0.3, explicitly marked deprecated.
-- `/api/v2` treated as primary client integration surface.
+3. **Privacy: Message Cache Control**
+ - New toggle in **Settings → Data & Privacy**: "Save messages to encrypted cache"
+ - Default: OFF (messages cleared on client restart, privacy-first)
+ - When enabled: encrypted plaintext cache persists across restarts
+ - When disabled: `local_messages` stripped at save time, runtime session intact
+ - Respects user privacy choice — no message history leak on shared devices
-## Testing and Exit Criteria
+### Bug Fixes
-The following scenarios must be explicitly covered:
+1. **Friends List Filtering**
+ - Contact panel now shows only direct messages (no group DMs)
+ - Group conversations remain in main conversation list but excluded from Friends/contacts sidebar
-- Group lifecycle integration: create, invite, accept, rename, leave, remove, ownership transfer.
-- Group call lifecycle integration: offer/join/reject/leave/end across local and federated peers.
-- Multi-device behavior: typing/read/attachment state coherence across sender and recipient devices.
-- Reconnect resilience: websocket reconnect with pending events and no duplicate-visible artifacts.
-- Attachment UX: oversized payload rejection, rate-limit rejection, queue-pressure rejection, user-visible retry path.
-- Compatibility regression: v1 routes continue to work while v2-first client flows stay green.
-- Security regression: no bypass of device-bound auth checks and no integrity-warning regressions.
+2. **Call Message Cache Formatting**
+ - Call history entries (system messages) retain CALL icon & styling after conversation switches
+ - Fixed: `sender_address` backfill no longer corrupts the system message detection flag
-## Risks and Mitigations
+3. **Call Target Stability**
+ - Call panel peer name now derives from `call_state_` (actual call target), not selected conversation
+ - Switching DMs during active call no longer changes the displayed call recipient
-- Risk: scope spread across group, UX, and quality.
-- Mitigation: strict phase gates with must-pass exit criteria per phase.
+### Data Integrity Fixes
-- Risk: dual-stack complexity (`/api/v1` + `/api/v2`).
-- Mitigation: explicit deprecation messaging and migration checklist in v0.3c.
-
-- Risk: federation edge-case drift.
-- Mitigation: mandatory federated integration scenarios in each phase test matrix.
-
-## Non-Goals for v0.3
-
-- Removing `/api/v1` in this release.
-- Large protocol redesign beyond ratchet/WebRTC paths already established in v0.2.
-- New client platforms beyond existing server + Qt client scope.
-
-## Assumptions and Defaults
-
-- Audience for `V0.3_Update.md`: engineering-facing roadmap that remains externally readable.
-- All phase details are planned work, not completed work.
-- Existing v0.2b foundations (ratchet/WebRTC scaffolding) are baseline, not re-proposed.
-- "New features and qualities" preference is implemented as:
-- Group GA first (`0.3a`)
-- Typing/read/attachment UX next (`0.3b`)
-- Stabilization and deprecation readiness last (`0.3c`)
-- Timeline is phase-based (`0.3a/0.3b/0.3c`) rather than calendar-date commitments.
+1. **JSON Deserialization Hardening**
+ - Replaced fragile `nlohmann::json.at("field").get_to()` with null-safe `j.value("field", "")` patterns
+ - Prevents `json.exception.type_error.302` crashes when state fields are null during persistence load
+ - Covers: `UserOut`, `DeviceOut`, `ConversationOut`, `LocalMessage`, `DeviceRegisterRequest`, `UserDeviceLookup`
+ - Initialization no longer crashes with "type must be string, but is null" errors
diff --git a/client-cpp-gui/CMakeLists.txt b/client-cpp-gui/CMakeLists.txt
index 0333f61..4a4fea0 100644
--- a/client-cpp-gui/CMakeLists.txt
+++ b/client-cpp-gui/CMakeLists.txt
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24)
-project(blackwire_client_cpp_gui VERSION 0.1.0 LANGUAGES CXX)
+project(blackwire_client_cpp_gui VERSION 0.3.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -7,7 +7,7 @@ set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)
-find_package(Qt6 REQUIRED COMPONENTS Core Widgets Network WebSockets Multimedia)
+find_package(Qt6 REQUIRED COMPONENTS Core Widgets Network WebSockets Multimedia MultimediaWidgets)
find_package(nlohmann_json CONFIG REQUIRED)
find_package(unofficial-sodium CONFIG REQUIRED)
find_package(spdlog CONFIG REQUIRED)
@@ -76,6 +76,7 @@ target_link_libraries(blackwire_client PRIVATE
Qt6::Network
Qt6::WebSockets
Qt6::Multimedia
+ Qt6::MultimediaWidgets
nlohmann_json::nlohmann_json
unofficial-sodium::sodium
spdlog::spdlog
@@ -85,7 +86,7 @@ if (WIN32)
target_link_libraries(blackwire_client PRIVATE Advapi32)
endif()
-target_compile_definitions(blackwire_client PRIVATE BLACKWIRE_CLIENT_VERSION=\"0.1.0\")
+target_compile_definitions(blackwire_client PRIVATE BLACKWIRE_CLIENT_VERSION=\"${PROJECT_VERSION}\")
include(CTest)
if (BUILD_TESTING)
diff --git a/client-cpp-gui/include/blackwire/api/qt_api_client.hpp b/client-cpp-gui/include/blackwire/api/qt_api_client.hpp
index 8177bd8..053f9ec 100644
--- a/client-cpp-gui/include/blackwire/api/qt_api_client.hpp
+++ b/client-cpp-gui/include/blackwire/api/qt_api_client.hpp
@@ -125,6 +125,27 @@ class QtApiClient final : public QObject, public IApiClient {
const std::string& access_token,
const std::string& conversation_id) override;
+ ConversationTypingResponse SendConversationTyping(
+ const std::string& base_url,
+ const std::string& access_token,
+ const std::string& conversation_id,
+ const ConversationTypingRequest& request) override;
+
+ ConversationReadCursorOut SendConversationRead(
+ const std::string& base_url,
+ const std::string& access_token,
+ const std::string& conversation_id,
+ const ConversationReadRequest& request) override;
+
+ ConversationReadStateOut GetConversationReadState(
+ const std::string& base_url,
+ const std::string& access_token,
+ const std::string& conversation_id) override;
+
+ SystemVersionOut GetSystemVersion(
+ const std::string& base_url,
+ const std::string& access_token) override;
+
std::vector ListMessages(
const std::string& base_url,
const std::string& access_token,
diff --git a/client-cpp-gui/include/blackwire/controller/application_controller.hpp b/client-cpp-gui/include/blackwire/controller/application_controller.hpp
index 0afce19..99c0902 100644
--- a/client-cpp-gui/include/blackwire/controller/application_controller.hpp
+++ b/client-cpp-gui/include/blackwire/controller/application_controller.hpp
@@ -71,8 +71,13 @@ class ApplicationController final : public QObject {
void SetPreferredAudioDevices(const QString& input_device_id, const QString& output_device_id);
bool AcceptMessagesFromStrangers() const;
void SetAcceptMessagesFromStrangers(bool enabled);
+ bool SaveMessageCache() const;
+ void SetSaveMessageCache(bool enabled);
void AcceptMessageRequest(const QString& conversation_id);
void IgnoreMessageRequest(const QString& conversation_id);
+ void PublishTypingState(const QString& conversation_id, bool typing);
+ void RetryFailedAttachment(const QString& message_id);
+ void LoadSystemVersion();
void ResetLocalState();
QString UserDisplayId() const;
@@ -81,6 +86,8 @@ class ApplicationController final : public QObject {
QString ConnectionStatus() const;
QString DiagnosticsReport() const;
QString ServerAuthority() const;
+ QString ClientVersion() const;
+ QString ServerVersion() const;
signals:
void AuthStateChanged(bool authenticated, const QString& username);
@@ -88,6 +95,7 @@ class ApplicationController final : public QObject {
void ConversationListChanged(const std::vector& items);
void ConversationSelected(const QString& conversation_id, const std::vector& thread_messages);
void IncomingMessage(const QString& conversation_id, const ThreadMessageView& thread_message);
+ void TypingIndicatorChanged(const QString& conversation_id, const QString& text);
void MessageRequestReceived(
const QString& conversation_id,
const QString& sender_username,
@@ -136,6 +144,15 @@ class ApplicationController final : public QObject {
void RefreshConversationList();
QString RenderMessage(const MessageOut& message, const std::string& plaintext) const;
std::vector RenderThread(const std::string& conversation_id) const;
+ QString BuildTypingIndicatorText(const std::string& conversation_id) const;
+ void PruneExpiredTypingIndicators();
+ bool PublishReadCursorForConversation(const std::string& conversation_id);
+ void MergeReadCursor(
+ const std::string& conversation_id,
+ const QString& reader_user_address,
+ const QString& last_read_message_id,
+ long long last_read_sent_at_ms,
+ const QString& updated_at);
std::optional NormalizePeerUsername(const QString& value, QString* error) const;
bool IsWebSocketAuthError(const std::string& error) const;
void ReauthenticateWebSocket();
@@ -196,6 +213,17 @@ class ApplicationController final : public QObject {
std::unordered_map peer_attachment_policy_cache_;
std::optional local_attachment_policy_cache_;
std::unordered_map peer_presence_status_by_address_;
+ struct ReadCursorState {
+ QString last_read_message_id;
+ long long last_read_sent_at_ms = 0;
+ QString updated_at;
+ };
+ std::unordered_map> read_cursors_by_conversation_;
+ std::unordered_map last_published_read_sent_at_by_conversation_;
+ std::unordered_map> typing_expiry_ms_by_conversation_;
+ std::unordered_map local_typing_state_by_conversation_;
+ QString server_version_ = "unknown";
+ QString client_version_ = "0.1.0";
QString user_presence_status_ = "active";
std::map> pending_request_messages_;
std::map pending_request_senders_;
@@ -205,6 +233,7 @@ class ApplicationController final : public QObject {
qint64 call_active_started_at_ms_ = 0;
bool pending_outgoing_end_request_ = false;
QTimer* presence_poll_timer_ = nullptr;
+ QTimer* typing_expiry_timer_ = nullptr;
int audio_sequence_ = 0;
};
diff --git a/client-cpp-gui/include/blackwire/interfaces/api_client.hpp b/client-cpp-gui/include/blackwire/interfaces/api_client.hpp
index 5e5a5ad..6503cfa 100644
--- a/client-cpp-gui/include/blackwire/interfaces/api_client.hpp
+++ b/client-cpp-gui/include/blackwire/interfaces/api_client.hpp
@@ -135,6 +135,27 @@ class IApiClient {
const std::string& access_token,
const std::string& conversation_id) = 0;
+ virtual ConversationTypingResponse SendConversationTyping(
+ const std::string& base_url,
+ const std::string& access_token,
+ const std::string& conversation_id,
+ const ConversationTypingRequest& request) = 0;
+
+ virtual ConversationReadCursorOut SendConversationRead(
+ const std::string& base_url,
+ const std::string& access_token,
+ const std::string& conversation_id,
+ const ConversationReadRequest& request) = 0;
+
+ virtual ConversationReadStateOut GetConversationReadState(
+ const std::string& base_url,
+ const std::string& access_token,
+ const std::string& conversation_id) = 0;
+
+ virtual SystemVersionOut GetSystemVersion(
+ const std::string& base_url,
+ const std::string& access_token) = 0;
+
virtual std::vector ListMessages(
const std::string& base_url,
const std::string& access_token,
diff --git a/client-cpp-gui/include/blackwire/interfaces/ws_client.hpp b/client-cpp-gui/include/blackwire/interfaces/ws_client.hpp
index 16da431..61a23d7 100644
--- a/client-cpp-gui/include/blackwire/interfaces/ws_client.hpp
+++ b/client-cpp-gui/include/blackwire/interfaces/ws_client.hpp
@@ -23,6 +23,8 @@ class IWsClient {
using CallWebRtcAnswerHandler = std::function;
using CallWebRtcIceHandler = std::function;
using GroupRenamedHandler = std::function;
+ using ConversationTypingHandler = std::function;
+ using ConversationReadHandler = std::function;
using ErrorHandler = std::function;
using StatusHandler = std::function;
@@ -43,6 +45,8 @@ class IWsClient {
CallWebRtcAnswerHandler on_call_webrtc_answer,
CallWebRtcIceHandler on_call_webrtc_ice,
GroupRenamedHandler on_group_renamed,
+ ConversationTypingHandler on_conversation_typing,
+ ConversationReadHandler on_conversation_read,
ErrorHandler on_error,
StatusHandler on_status) = 0;
virtual void Connect(const std::string& base_url, const std::string& access_token) = 0;
diff --git a/client-cpp-gui/include/blackwire/models/dto.hpp b/client-cpp-gui/include/blackwire/models/dto.hpp
index 39297c6..e5f76e7 100644
--- a/client-cpp-gui/include/blackwire/models/dto.hpp
+++ b/client-cpp-gui/include/blackwire/models/dto.hpp
@@ -239,6 +239,48 @@ struct PresenceResolveResponse {
std::vector peers;
};
+struct ConversationTypingRequest {
+ std::string state = "on";
+ long long client_ts_ms = 0;
+};
+
+struct ConversationTypingResponse {
+ bool ok = false;
+ int expires_in_ms = 0;
+};
+
+struct ConversationReadRequest {
+ std::string last_read_message_id;
+ long long last_read_sent_at_ms = 0;
+};
+
+struct ConversationReadCursorOut {
+ std::string conversation_id;
+ std::string reader_user_address;
+ std::string last_read_message_id;
+ long long last_read_sent_at_ms = 0;
+ std::string updated_at;
+};
+
+struct ConversationReadCursorEntryOut {
+ std::string user_address;
+ std::string last_read_message_id;
+ long long last_read_sent_at_ms = 0;
+ std::string updated_at;
+};
+
+struct ConversationReadStateOut {
+ std::string conversation_id;
+ std::vector cursors;
+};
+
+struct SystemVersionOut {
+ std::string server_version;
+ std::string api_version = "v2";
+ std::string git_commit;
+ std::string build_timestamp;
+};
+
struct MessageSendResponse {
bool duplicate = false;
MessageOut message;
@@ -418,6 +460,22 @@ struct WsEventGroupRenamed {
int event_seq = 0;
};
+struct WsEventConversationTyping {
+ std::string conversation_id;
+ std::string from_user_address;
+ std::string state = "off";
+ int expires_in_ms = 0;
+ std::string sent_at;
+};
+
+struct WsEventConversationRead {
+ std::string conversation_id;
+ std::string reader_user_address;
+ std::string last_read_message_id;
+ long long last_read_sent_at_ms = 0;
+ std::string updated_at;
+};
+
inline std::string JsonStringOrDefault(
const nlohmann::json& j,
const char* key,
@@ -444,9 +502,9 @@ inline void to_json(nlohmann::json& j, const UserOut& v) {
}
inline void from_json(const nlohmann::json& j, UserOut& v) {
- j.at("id").get_to(v.id);
- j.at("username").get_to(v.username);
- j.at("created_at").get_to(v.created_at);
+ v.id = j.value("id", "");
+ v.username = j.value("username", "");
+ v.created_at = j.value("created_at", "");
v.user_address = j.value("user_address", "");
v.home_server_onion = j.value("home_server_onion", "");
}
@@ -495,7 +553,7 @@ inline void to_json(nlohmann::json& j, const DeviceRegisterRequest& v) {
}
inline void from_json(const nlohmann::json& j, DeviceRegisterRequest& v) {
- j.at("label").get_to(v.label);
+ v.label = j.value("label", "");
v.ik_ed25519_pub = j.value("ik_ed25519_pub", j.value("pub_sign_key", ""));
v.enc_x25519_pub = j.value("enc_x25519_pub", j.value("pub_dh_key", ""));
v.pub_sign_key = j.value("pub_sign_key", v.ik_ed25519_pub);
@@ -523,8 +581,8 @@ inline void to_json(nlohmann::json& j, const DeviceOut& v) {
inline void from_json(const nlohmann::json& j, DeviceOut& v) {
v.id = j.value("id", j.value("device_uid", ""));
v.device_uid = j.value("device_uid", v.id);
- j.at("user_id").get_to(v.user_id);
- j.at("label").get_to(v.label);
+ v.user_id = j.value("user_id", "");
+ v.label = j.value("label", "");
v.ik_ed25519_pub = j.value("ik_ed25519_pub", j.value("pub_sign_key", ""));
v.enc_x25519_pub = j.value("enc_x25519_pub", j.value("pub_dh_key", ""));
v.status = j.value("status", "active");
@@ -535,7 +593,7 @@ inline void from_json(const nlohmann::json& j, DeviceOut& v) {
} else {
v.revoked_at.clear();
}
- j.at("created_at").get_to(v.created_at);
+ v.created_at = j.value("created_at", "");
}
inline void to_json(nlohmann::json& j, const UserDeviceLookup& v) {
@@ -551,7 +609,7 @@ inline void to_json(nlohmann::json& j, const UserDeviceLookup& v) {
}
inline void from_json(const nlohmann::json& j, UserDeviceLookup& v) {
- j.at("username").get_to(v.username);
+ v.username = j.value("username", "");
v.peer_address = j.value("peer_address", "");
if (j.contains("device")) {
j.at("device").get_to(v.device);
@@ -594,12 +652,12 @@ inline void to_json(nlohmann::json& j, const ConversationOut& v) {
}
inline void from_json(const nlohmann::json& j, ConversationOut& v) {
- j.at("id").get_to(v.id);
+ v.id = j.value("id", "");
v.kind = j.value("kind", "local");
v.user_a_id = j.value("user_a_id", "");
v.user_b_id = j.value("user_b_id", "");
v.local_user_id = j.value("local_user_id", "");
- j.at("created_at").get_to(v.created_at);
+ v.created_at = j.value("created_at", "");
v.peer_username = j.value("peer_username", "");
v.peer_server_onion = j.value("peer_server_onion", "");
v.peer_address = j.value("peer_address", "");
@@ -1096,6 +1154,104 @@ inline void from_json(const nlohmann::json& j, PresenceResolveResponse& v) {
v.peers = j.value("peers", std::vector{});
}
+inline void to_json(nlohmann::json& j, const ConversationTypingRequest& v) {
+ j = nlohmann::json{
+ {"state", v.state},
+ {"client_ts_ms", v.client_ts_ms > 0 ? nlohmann::json(v.client_ts_ms) : nlohmann::json(nullptr)},
+ };
+}
+
+inline void from_json(const nlohmann::json& j, ConversationTypingRequest& v) {
+ v.state = j.value("state", "on");
+ v.client_ts_ms = j.value("client_ts_ms", 0LL);
+}
+
+inline void to_json(nlohmann::json& j, const ConversationTypingResponse& v) {
+ j = nlohmann::json{
+ {"ok", v.ok},
+ {"expires_in_ms", v.expires_in_ms},
+ };
+}
+
+inline void from_json(const nlohmann::json& j, ConversationTypingResponse& v) {
+ v.ok = j.value("ok", false);
+ v.expires_in_ms = j.value("expires_in_ms", 0);
+}
+
+inline void to_json(nlohmann::json& j, const ConversationReadRequest& v) {
+ j = nlohmann::json{
+ {"last_read_message_id", v.last_read_message_id},
+ {"last_read_sent_at_ms", v.last_read_sent_at_ms},
+ };
+}
+
+inline void from_json(const nlohmann::json& j, ConversationReadRequest& v) {
+ v.last_read_message_id = j.value("last_read_message_id", "");
+ v.last_read_sent_at_ms = j.value("last_read_sent_at_ms", 0LL);
+}
+
+inline void to_json(nlohmann::json& j, const ConversationReadCursorOut& v) {
+ j = nlohmann::json{
+ {"conversation_id", v.conversation_id},
+ {"reader_user_address", v.reader_user_address},
+ {"last_read_message_id", v.last_read_message_id},
+ {"last_read_sent_at_ms", v.last_read_sent_at_ms},
+ {"updated_at", v.updated_at},
+ };
+}
+
+inline void from_json(const nlohmann::json& j, ConversationReadCursorOut& v) {
+ v.conversation_id = j.value("conversation_id", "");
+ v.reader_user_address = j.value("reader_user_address", "");
+ v.last_read_message_id = j.value("last_read_message_id", "");
+ v.last_read_sent_at_ms = j.value("last_read_sent_at_ms", 0LL);
+ v.updated_at = j.value("updated_at", "");
+}
+
+inline void to_json(nlohmann::json& j, const ConversationReadCursorEntryOut& v) {
+ j = nlohmann::json{
+ {"user_address", v.user_address},
+ {"last_read_message_id", v.last_read_message_id},
+ {"last_read_sent_at_ms", v.last_read_sent_at_ms},
+ {"updated_at", v.updated_at},
+ };
+}
+
+inline void from_json(const nlohmann::json& j, ConversationReadCursorEntryOut& v) {
+ v.user_address = j.value("user_address", "");
+ v.last_read_message_id = j.value("last_read_message_id", "");
+ v.last_read_sent_at_ms = j.value("last_read_sent_at_ms", 0LL);
+ v.updated_at = j.value("updated_at", "");
+}
+
+inline void to_json(nlohmann::json& j, const ConversationReadStateOut& v) {
+ j = nlohmann::json{
+ {"conversation_id", v.conversation_id},
+ {"cursors", v.cursors},
+ };
+}
+
+inline void from_json(const nlohmann::json& j, ConversationReadStateOut& v) {
+ v.conversation_id = j.value("conversation_id", "");
+ v.cursors = j.value("cursors", std::vector{});
+}
+
+inline void to_json(nlohmann::json& j, const SystemVersionOut& v) {
+ j = nlohmann::json{
+ {"server_version", v.server_version},
+ {"api_version", v.api_version},
+ {"git_commit", v.git_commit},
+ {"build_timestamp", v.build_timestamp},
+ };
+}
+
+inline void from_json(const nlohmann::json& j, SystemVersionOut& v) {
+ v.server_version = j.value("server_version", "");
+ v.api_version = j.value("api_version", "v2");
+ v.git_commit = j.value("git_commit", "");
+ v.build_timestamp = j.value("build_timestamp", "");
+}
+
inline void from_json(const nlohmann::json& j, WsEventMessageNew& v) {
v.copy_id = j.value("copy_id", "");
j.at("message").get_to(v.message);
@@ -1344,4 +1500,20 @@ inline void from_json(const nlohmann::json& j, WsEventGroupRenamed& v) {
v.event_seq = j.value("event_seq", 0);
}
+inline void from_json(const nlohmann::json& j, WsEventConversationTyping& v) {
+ v.conversation_id = j.value("conversation_id", "");
+ v.from_user_address = j.value("from_user_address", "");
+ v.state = j.value("state", "off");
+ v.expires_in_ms = j.value("expires_in_ms", 0);
+ v.sent_at = j.value("sent_at", "");
+}
+
+inline void from_json(const nlohmann::json& j, WsEventConversationRead& v) {
+ v.conversation_id = j.value("conversation_id", "");
+ v.reader_user_address = j.value("reader_user_address", "");
+ v.last_read_message_id = j.value("last_read_message_id", "");
+ v.last_read_sent_at_ms = j.value("last_read_sent_at_ms", 0LL);
+ v.updated_at = j.value("updated_at", "");
+}
+
} // namespace blackwire
diff --git a/client-cpp-gui/include/blackwire/models/view_models.hpp b/client-cpp-gui/include/blackwire/models/view_models.hpp
index 649fd91..d6a4a73 100644
--- a/client-cpp-gui/include/blackwire/models/view_models.hpp
+++ b/client-cpp-gui/include/blackwire/models/view_models.hpp
@@ -56,10 +56,19 @@ struct ThreadMessageView {
QString id;
QString sender_label;
QString body;
+ QString render_mode = "plain"; // plain|markdown|attachment
+ bool system = false;
QString created_at_iso;
QString created_at_display;
+ long long sent_at_ms = 0;
bool outgoing = false;
bool grouped_with_previous = false;
+ QString attachment_name;
+ QString attachment_mime_type;
+ QString attachment_media_kind;
+ QString attachment_status = "success"; // queued|sending|success|failed
+ bool attachment_retryable = false;
+ QString delivery_badge;
};
} // namespace blackwire
diff --git a/client-cpp-gui/include/blackwire/storage/client_state.hpp b/client-cpp-gui/include/blackwire/storage/client_state.hpp
index 4e1dad8..5b212c1 100644
--- a/client-cpp-gui/include/blackwire/storage/client_state.hpp
+++ b/client-cpp-gui/include/blackwire/storage/client_state.hpp
@@ -17,9 +17,15 @@ struct LocalMessage {
std::string sender_user_id;
std::string sender_address;
std::string created_at;
+ long long sent_at_ms = 0;
std::string rendered_text;
std::string plaintext;
std::string plaintext_cache_b64;
+ std::string attachment_name;
+ std::string attachment_mime_type;
+ std::string attachment_media_kind;
+ std::string attachment_status = "success";
+ std::string retry_payload;
};
struct ConversationMeta {
@@ -36,6 +42,7 @@ struct AudioPreferences {
struct SocialPreferences {
bool accept_messages_from_strangers = true;
+ bool save_message_cache = true;
std::string presence_status = "active";
};
@@ -69,9 +76,19 @@ inline void to_json(nlohmann::json& j, const LocalMessage& v) {
{"sender_address", v.sender_address.empty() ? nlohmann::json(nullptr)
: nlohmann::json(v.sender_address)},
{"created_at", v.created_at},
+ {"sent_at_ms", v.sent_at_ms},
{"rendered_text", v.rendered_text},
{"plaintext_cache_b64", v.plaintext_cache_b64.empty() ? nlohmann::json(nullptr)
- : nlohmann::json(v.plaintext_cache_b64)}};
+ : nlohmann::json(v.plaintext_cache_b64)},
+ {"attachment_name", v.attachment_name.empty() ? nlohmann::json(nullptr)
+ : nlohmann::json(v.attachment_name)},
+ {"attachment_mime_type", v.attachment_mime_type.empty() ? nlohmann::json(nullptr)
+ : nlohmann::json(v.attachment_mime_type)},
+ {"attachment_media_kind", v.attachment_media_kind.empty() ? nlohmann::json(nullptr)
+ : nlohmann::json(v.attachment_media_kind)},
+ {"attachment_status", v.attachment_status},
+ {"retry_payload", v.retry_payload.empty() ? nlohmann::json(nullptr)
+ : nlohmann::json(v.retry_payload)}};
}
inline void to_json(nlohmann::json& j, const ConversationMeta& v) {
@@ -84,20 +101,22 @@ inline void to_json(nlohmann::json& j, const ConversationMeta& v) {
}
inline void from_json(const nlohmann::json& j, LocalMessage& v) {
- j.at("id").get_to(v.id);
- j.at("conversation_id").get_to(v.conversation_id);
- j.at("sender_user_id").get_to(v.sender_user_id);
- v.sender_address = j.value("sender_address", "");
- j.at("created_at").get_to(v.created_at);
+ v.id = j.value("id", "");
+ v.conversation_id = j.value("conversation_id", "");
+ v.sender_user_id = j.value("sender_user_id", "");
+ v.sender_address = JsonStringOrDefault(j, "sender_address");
+ v.created_at = j.value("created_at", "");
+ v.sent_at_ms = j.value("sent_at_ms", 0LL);
// Scrub any historical plaintext payload that may have been persisted.
v.rendered_text = "[encrypted message]";
// Never deserialize historical plaintext from disk into runtime state.
v.plaintext.clear();
- if (j.contains("plaintext_cache_b64") && !j.at("plaintext_cache_b64").is_null()) {
- v.plaintext_cache_b64 = j.value("plaintext_cache_b64", "");
- } else {
- v.plaintext_cache_b64.clear();
- }
+ v.plaintext_cache_b64 = JsonStringOrDefault(j, "plaintext_cache_b64");
+ v.attachment_name = JsonStringOrDefault(j, "attachment_name");
+ v.attachment_mime_type = JsonStringOrDefault(j, "attachment_mime_type");
+ v.attachment_media_kind = JsonStringOrDefault(j, "attachment_media_kind");
+ v.attachment_status = JsonStringOrDefault(j, "attachment_status", "success");
+ v.retry_payload = JsonStringOrDefault(j, "retry_payload");
}
inline void from_json(const nlohmann::json& j, ConversationMeta& v) {
@@ -127,12 +146,14 @@ inline void from_json(const nlohmann::json& j, AudioPreferences& v) {
inline void to_json(nlohmann::json& j, const SocialPreferences& v) {
j = nlohmann::json{
{"accept_messages_from_strangers", v.accept_messages_from_strangers},
+ {"save_message_cache", v.save_message_cache},
{"presence_status", v.presence_status},
};
}
inline void from_json(const nlohmann::json& j, SocialPreferences& v) {
v.accept_messages_from_strangers = j.value("accept_messages_from_strangers", true);
+ v.save_message_cache = j.value("save_message_cache", true);
v.presence_status = j.value("presence_status", "active");
}
diff --git a/client-cpp-gui/include/blackwire/ui/chat_widget.hpp b/client-cpp-gui/include/blackwire/ui/chat_widget.hpp
index 77ee11a..d729a80 100644
--- a/client-cpp-gui/include/blackwire/ui/chat_widget.hpp
+++ b/client-cpp-gui/include/blackwire/ui/chat_widget.hpp
@@ -38,6 +38,7 @@ class ChatWidget final : public QWidget {
void SetIdentity(const QString& user_address);
void SetCallState(const CallStateView& state);
void SetUserStatus(const QString& status);
+ void SetTypingIndicator(const QString& conversation_id, const QString& text);
void ShowBanner(const QString& text, const QString& severity);
void ClearCompose();
void SetSendEnabled(bool enabled);
@@ -53,6 +54,8 @@ class ChatWidget final : public QWidget {
const QString& title);
void SendMessageRequested();
void SendFileRequested(const QString& file_path);
+ void RetryAttachmentRequested(const QString& message_id);
+ void TypingStateChanged(const QString& conversation_id, bool typing);
void SettingsRequested();
void StartVoiceCallRequested();
void AcceptVoiceCallRequested();
@@ -68,7 +71,7 @@ class ChatWidget final : public QWidget {
private:
bool eventFilter(QObject* watched, QEvent* event) override;
QWidget* CreateConversationItemWidget(const ConversationListItemView& item, bool selected, bool removable);
- QWidget* CreateThreadMessageWidget(const ThreadMessageView& message) const;
+ QWidget* CreateThreadMessageWidget(const ThreadMessageView& message);
bool IsTimelineNearBottom() const;
void ScrollTimelineToBottom();
void SetTimelineHasMessages(bool has_messages);
@@ -117,12 +120,16 @@ class ChatWidget final : public QWidget {
QLabel* identity_label_;
QPushButton* copy_identity_button_;
QLabel* banner_label_;
+ QLabel* typing_indicator_label_;
QLineEdit* thread_title_label_;
QLabel* empty_state_label_;
QTimer* banner_timer_;
+ QTimer* typing_idle_timer_;
QString identity_value_;
CallStateView call_state_;
bool contacts_mode_active_ = true;
+ bool local_typing_active_ = false;
+ QString local_typing_conversation_id_;
};
} // namespace blackwire
diff --git a/client-cpp-gui/include/blackwire/ui/settings_dialog.hpp b/client-cpp-gui/include/blackwire/ui/settings_dialog.hpp
index 43428d6..4564e44 100644
--- a/client-cpp-gui/include/blackwire/ui/settings_dialog.hpp
+++ b/client-cpp-gui/include/blackwire/ui/settings_dialog.hpp
@@ -34,6 +34,7 @@ class SettingsDialog final : public QDialog {
void SetIdentity(const QString& identity);
void SetServerUrl(const QString& server_url);
void SetDeviceInfo(const QString& label, const QString& device_id);
+ void SetVersionInfo(const QString& client_version, const QString& server_version);
void SetConnectionStatus(const QString& status);
void SetDiagnostics(const QString& diagnostics);
void SetAccountDevices(const std::vector& devices, const QString& current_device_uid);
@@ -42,8 +43,10 @@ class SettingsDialog final : public QDialog {
const std::vector& output_devices);
void SetSelectedAudioDevices(const QString& input_device_id, const QString& output_device_id);
void SetAcceptMessagesFromStrangers(bool enabled);
+ void SetSaveMessageCache(bool enabled);
void SetIntegrityWarning(const QString& warning);
bool AcceptMessagesFromStrangers() const;
+ bool SaveMessageCache() const;
protected:
void closeEvent(QCloseEvent* event) override;
@@ -55,6 +58,7 @@ class SettingsDialog final : public QDialog {
void RevokeDeviceRequested(const QString& device_uid);
void ApplyAudioDevicesRequested(const QString& input_device_id, const QString& output_device_id);
void AcceptMessagesFromStrangersChanged(bool enabled);
+ void SaveMessageCacheChanged(bool enabled);
private:
void BuildLayout();
@@ -83,6 +87,8 @@ class SettingsDialog final : public QDialog {
QLabel* server_url_value_ = nullptr;
QLabel* device_label_value_ = nullptr;
QLabel* device_id_value_ = nullptr;
+ QLabel* client_version_value_ = nullptr;
+ QLabel* server_version_value_ = nullptr;
QLabel* connection_status_value_ = nullptr;
QListWidget* account_devices_list_ = nullptr;
QPushButton* revoke_device_button_ = nullptr;
@@ -90,6 +96,7 @@ class SettingsDialog final : public QDialog {
QComboBox* output_device_combo_ = nullptr;
QPushButton* apply_audio_button_ = nullptr;
QCheckBox* accept_messages_checkbox_ = nullptr;
+ QCheckBox* save_message_cache_checkbox_ = nullptr;
QCheckBox* mic_monitor_checkbox_ = nullptr;
QProgressBar* mic_level_meter_ = nullptr;
QLabel* mic_monitor_status_ = nullptr;
diff --git a/client-cpp-gui/include/blackwire/ws/qt_ws_client.hpp b/client-cpp-gui/include/blackwire/ws/qt_ws_client.hpp
index 97d8e4d..67bb70a 100644
--- a/client-cpp-gui/include/blackwire/ws/qt_ws_client.hpp
+++ b/client-cpp-gui/include/blackwire/ws/qt_ws_client.hpp
@@ -29,6 +29,8 @@ class QtWsClient final : public QObject, public IWsClient {
CallWebRtcAnswerHandler on_call_webrtc_answer,
CallWebRtcIceHandler on_call_webrtc_ice,
GroupRenamedHandler on_group_renamed,
+ ConversationTypingHandler on_conversation_typing,
+ ConversationReadHandler on_conversation_read,
ErrorHandler on_error,
StatusHandler on_status) override;
void Connect(const std::string& base_url, const std::string& access_token) override;
@@ -65,6 +67,8 @@ class QtWsClient final : public QObject, public IWsClient {
CallWebRtcAnswerHandler on_call_webrtc_answer_;
CallWebRtcIceHandler on_call_webrtc_ice_;
GroupRenamedHandler on_group_renamed_;
+ ConversationTypingHandler on_conversation_typing_;
+ ConversationReadHandler on_conversation_read_;
ErrorHandler on_error_;
StatusHandler on_status_;
diff --git a/client-cpp-gui/src/api/qt_api_client.cpp b/client-cpp-gui/src/api/qt_api_client.cpp
index a65199e..b67f4c4 100644
--- a/client-cpp-gui/src/api/qt_api_client.cpp
+++ b/client-cpp-gui/src/api/qt_api_client.cpp
@@ -405,6 +405,57 @@ ConversationRecipientsOut QtApiClient::GetConversationRecipients(
return json.get();
}
+ConversationTypingResponse QtApiClient::SendConversationTyping(
+ const std::string& base_url,
+ const std::string& access_token,
+ const std::string& conversation_id,
+ const ConversationTypingRequest& request) {
+ const nlohmann::json body = request;
+ const auto json = RequestJson(
+ "POST",
+ JoinUrl(base_url, QString("/conversations/%1/typing").arg(QString::fromStdString(conversation_id))),
+ QString::fromStdString(access_token),
+ &body);
+ return json.get();
+}
+
+ConversationReadCursorOut QtApiClient::SendConversationRead(
+ const std::string& base_url,
+ const std::string& access_token,
+ const std::string& conversation_id,
+ const ConversationReadRequest& request) {
+ const nlohmann::json body = request;
+ const auto json = RequestJson(
+ "POST",
+ JoinUrl(base_url, QString("/conversations/%1/read").arg(QString::fromStdString(conversation_id))),
+ QString::fromStdString(access_token),
+ &body);
+ return json.get();
+}
+
+ConversationReadStateOut QtApiClient::GetConversationReadState(
+ const std::string& base_url,
+ const std::string& access_token,
+ const std::string& conversation_id) {
+ const auto json = RequestJson(
+ "GET",
+ JoinUrl(base_url, QString("/conversations/%1/read").arg(QString::fromStdString(conversation_id))),
+ QString::fromStdString(access_token),
+ nullptr);
+ return json.get();
+}
+
+SystemVersionOut QtApiClient::GetSystemVersion(
+ const std::string& base_url,
+ const std::string& access_token) {
+ const auto json = RequestJson(
+ "GET",
+ JoinUrl(base_url, "/system/version"),
+ QString::fromStdString(access_token),
+ nullptr);
+ return json.get();
+}
+
std::vector QtApiClient::ListMessages(
const std::string& base_url,
const std::string& access_token,
diff --git a/client-cpp-gui/src/controller/application_controller.cpp b/client-cpp-gui/src/controller/application_controller.cpp
index 96ce9e5..75bc6bc 100644
--- a/client-cpp-gui/src/controller/application_controller.cpp
+++ b/client-cpp-gui/src/controller/application_controller.cpp
@@ -14,6 +14,8 @@
#include
#include
#include
+#include
+#include
#include
#include
#include
@@ -78,6 +80,68 @@ QString UsernameFromAddress(const QString& actor_address) {
return normalized;
}
+QString ClientVersionString() {
+ const char* version = "0.3.0";
+#ifdef BLACKWIRE_CLIENT_VERSION
+ version = BLACKWIRE_CLIENT_VERSION;
+#endif
+ return QString::fromUtf8(version);
+}
+
+QString MediaKindFromMime(const QString& mime_type) {
+ const QString normalized = mime_type.trimmed().toLower();
+ if (normalized.startsWith("image/")) {
+ return "image";
+ }
+ if (normalized.startsWith("video/")) {
+ return "video";
+ }
+ return "file";
+}
+
+bool ParseAttachmentEnvelope(
+ const QString& marker_body,
+ QString* name,
+ QString* mime_type,
+ QString* media_kind) {
+ if (!marker_body.startsWith(kFileMessagePrefix, Qt::CaseInsensitive)) {
+ return false;
+ }
+ const QString encoded = marker_body.mid(static_cast(strlen(kFileMessagePrefix))).trimmed();
+ if (encoded.isEmpty()) {
+ return false;
+ }
+ const QByteArray decoded_json = QByteArray::fromBase64(encoded.toUtf8());
+ if (decoded_json.isEmpty()) {
+ return false;
+ }
+ QJsonParseError parse_error{};
+ const QJsonDocument doc = QJsonDocument::fromJson(decoded_json, &parse_error);
+ if (parse_error.error != QJsonParseError::NoError || !doc.isObject()) {
+ return false;
+ }
+ const QJsonObject obj = doc.object();
+ const QString parsed_name = obj.value("name").toString().trimmed();
+ if (parsed_name.isEmpty()) {
+ return false;
+ }
+ QString parsed_mime = obj.value("mime_type").toString().trimmed().toLower();
+ QString parsed_kind = obj.value("media_kind").toString().trimmed().toLower();
+ if (parsed_kind.isEmpty()) {
+ parsed_kind = MediaKindFromMime(parsed_mime);
+ }
+ if (name != nullptr) {
+ *name = parsed_name;
+ }
+ if (mime_type != nullptr) {
+ *mime_type = parsed_mime;
+ }
+ if (media_kind != nullptr) {
+ *media_kind = parsed_kind;
+ }
+ return true;
+}
+
std::string CanonicalMessageSignature(
const std::string& sender_address,
const std::string& sender_device_uid,
@@ -150,6 +214,7 @@ ApplicationController::ApplicationController(
qRegisterMetaType("blackwire::CallStateView");
qRegisterMetaType("blackwire::ThreadMessageView");
qRegisterMetaType>("std::vector");
+ client_version_ = ClientVersionString();
presence_poll_timer_ = new QTimer(this);
presence_poll_timer_->setInterval(5000);
@@ -161,6 +226,11 @@ ApplicationController::ApplicationController(
});
presence_poll_timer_->start();
+ typing_expiry_timer_ = new QTimer(this);
+ typing_expiry_timer_->setInterval(1000);
+ connect(typing_expiry_timer_, &QTimer::timeout, this, [this]() { PruneExpiredTypingIndicators(); });
+ typing_expiry_timer_->start();
+
ws_client_.SetHandlers(
[this](const WsEventMessageNew& event) {
try {
@@ -256,8 +326,25 @@ ApplicationController::ApplicationController(
local.sender_user_id = msg.sender_user_id;
local.sender_address = msg.sender_address;
local.created_at = msg.created_at;
+ local.sent_at_ms = msg.sent_at_ms;
local.rendered_text = RenderMessage(msg, plaintext).toStdString();
local.plaintext = plaintext;
+ local.attachment_status = "success";
+ local.retry_payload.clear();
+ {
+ QString attachment_name;
+ QString attachment_mime_type;
+ QString attachment_media_kind;
+ if (ParseAttachmentEnvelope(
+ QString::fromStdString(plaintext),
+ &attachment_name,
+ &attachment_mime_type,
+ &attachment_media_kind)) {
+ local.attachment_name = attachment_name.toStdString();
+ local.attachment_mime_type = attachment_mime_type.toStdString();
+ local.attachment_media_kind = attachment_media_kind.toStdString();
+ }
+ }
if (state_.dismissed_conversation_ids.contains(msg.conversation_id)) {
state_.dismissed_conversation_ids.erase(msg.conversation_id);
}
@@ -329,6 +416,7 @@ ApplicationController::ApplicationController(
const auto thread = RenderThread(msg.conversation_id);
if (selected_conversation_id_ == msg.conversation_id) {
+ PublishReadCursorForConversation(msg.conversation_id);
emit ConversationSelected(
QString::fromStdString(msg.conversation_id),
thread);
@@ -828,6 +916,54 @@ ApplicationController::ApplicationController(
emit ConversationSelected(conversation_id, RenderThread(selected_conversation_id_));
}
},
+ [this](const WsEventConversationTyping& event) {
+ const std::string conversation_id = event.conversation_id;
+ if (conversation_id.empty()) {
+ return;
+ }
+ const QString from_address = QString::fromStdString(event.from_user_address).trimmed().toLower();
+ const QString self_address = UserDisplayId().trimmed().toLower();
+ if (!from_address.isEmpty() && from_address == self_address) {
+ return;
+ }
+
+ const QString state = QString::fromStdString(event.state).trimmed().toLower();
+ auto& by_sender = typing_expiry_ms_by_conversation_[conversation_id];
+ if (state == "off") {
+ if (!from_address.isEmpty()) {
+ by_sender.erase(from_address.toStdString());
+ }
+ } else {
+ const int ttl_ms = std::max(1000, event.expires_in_ms);
+ by_sender[from_address.toStdString()] = QDateTime::currentMSecsSinceEpoch() + ttl_ms;
+ }
+ if (by_sender.empty()) {
+ typing_expiry_ms_by_conversation_.erase(conversation_id);
+ }
+
+ if (selected_conversation_id_ == conversation_id) {
+ emit TypingIndicatorChanged(
+ QString::fromStdString(conversation_id),
+ BuildTypingIndicatorText(conversation_id));
+ }
+ },
+ [this](const WsEventConversationRead& event) {
+ const std::string conversation_id = event.conversation_id;
+ if (conversation_id.empty()) {
+ return;
+ }
+ MergeReadCursor(
+ conversation_id,
+ QString::fromStdString(event.reader_user_address),
+ QString::fromStdString(event.last_read_message_id),
+ event.last_read_sent_at_ms,
+ QString::fromStdString(event.updated_at));
+ if (selected_conversation_id_ == conversation_id) {
+ emit ConversationSelected(
+ QString::fromStdString(conversation_id),
+ RenderThread(conversation_id));
+ }
+ },
[this](const std::string& error) {
if (IsWebSocketAuthError(error)) {
ReauthenticateWebSocket();
@@ -881,6 +1017,7 @@ void ApplicationController::Initialize() {
if (state_.has_user && state_.has_device) {
LoadConversations();
StartRealtime();
+ LoadSystemVersion();
SetPresenceStatus(preferred_presence_status);
}
} catch (const std::exception& ex) {
@@ -991,6 +1128,7 @@ void ApplicationController::Login(const QString& username, const QString& passwo
PersistState();
LoadConversations();
StartRealtime();
+ LoadSystemVersion();
SetPresenceStatus(preferred_presence_status);
} catch (const std::exception& ex) {
const QString line = QString("Device re-bind failed: %1").arg(ex.what());
@@ -1084,6 +1222,7 @@ void ApplicationController::SetupDevice(const QString& label) {
LoadConversations();
StartRealtime();
+ LoadSystemVersion();
SetPresenceStatus(preferred_presence_status);
} catch (const std::exception& ex) {
const QString line = QString("Device setup failed: %1").arg(ex.what());
@@ -1348,6 +1487,29 @@ void ApplicationController::SelectConversation(const QString& conversation_id) {
existing->second.sender_address = self_address.toStdString();
}
}
+ if (existing->second.sent_at_ms <= 0) {
+ existing->second.sent_at_ms = message.sent_at_ms;
+ }
+ if (existing->second.attachment_status.empty()) {
+ existing->second.attachment_status = "success";
+ }
+ if (existing->second.attachment_name.empty()) {
+ const QString plaintext_value = existing->second.plaintext.empty()
+ ? QString::fromStdString(existing->second.rendered_text)
+ : QString::fromStdString(existing->second.plaintext);
+ QString attachment_name;
+ QString attachment_mime_type;
+ QString attachment_media_kind;
+ if (ParseAttachmentEnvelope(
+ plaintext_value,
+ &attachment_name,
+ &attachment_mime_type,
+ &attachment_media_kind)) {
+ existing->second.attachment_name = attachment_name.toStdString();
+ existing->second.attachment_mime_type = attachment_mime_type.toStdString();
+ existing->second.attachment_media_kind = attachment_media_kind.toStdString();
+ }
+ }
rebuilt.push_back(existing->second);
rebuilt_ids.insert(existing->second.id);
state_.MarkMessageSeen(message.id);
@@ -1375,8 +1537,25 @@ void ApplicationController::SelectConversation(const QString& conversation_id) {
? self_address.toStdString()
: message.sender_address;
local.created_at = message.created_at;
+ local.sent_at_ms = message.sent_at_ms;
local.rendered_text = RenderMessage(message, plaintext).toStdString();
local.plaintext = plaintext;
+ local.attachment_status = "success";
+ local.retry_payload.clear();
+ {
+ QString attachment_name;
+ QString attachment_mime_type;
+ QString attachment_media_kind;
+ if (ParseAttachmentEnvelope(
+ QString::fromStdString(plaintext),
+ &attachment_name,
+ &attachment_mime_type,
+ &attachment_media_kind)) {
+ local.attachment_name = attachment_name.toStdString();
+ local.attachment_mime_type = attachment_mime_type.toStdString();
+ local.attachment_media_kind = attachment_media_kind.toStdString();
+ }
+ }
rebuilt.push_back(local);
rebuilt_ids.insert(local.id);
@@ -1388,18 +1567,19 @@ void ApplicationController::SelectConversation(const QString& conversation_id) {
if (rebuilt_ids.contains(existing.id)) {
continue;
}
- // Server listing is device-copy scoped and paginated, so preserve
- // self/system/older-cached entries that are not in this window.
- const bool self_sent = existing.sender_user_id == state_.user.id;
- const bool local_system_entry = existing.sender_user_id.empty();
- bool older_than_fetch_window = false;
- if (oldest_remote_time.isValid()) {
- const QDateTime existing_time = parse_time(existing.created_at);
- older_than_fetch_window = existing_time.isValid() && existing_time < oldest_remote_time;
- }
- if (self_sent || local_system_entry || older_than_fetch_window) {
+ // Server listing is device-copy scoped and paginated.
+ // Preserve ALL locally-cached entries that the server did
+ // not return — this includes received messages that were
+ // delivered via WebSocket and may no longer appear in the
+ // server's paginated listing after acknowledgement.
+ {
LocalMessage carry = existing;
- if (carry.sender_address.empty()) {
+ // Only backfill sender_address for real user messages.
+ // System entries (call history etc.) must keep both
+ // sender_user_id and sender_address empty so that
+ // BuildThreadMessageViews marks them as system.
+ const bool local_system_entry = existing.sender_user_id.empty();
+ if (carry.sender_address.empty() && !local_system_entry) {
carry.sender_address = self_address.toStdString();
}
rebuilt.push_back(std::move(carry));
@@ -1419,6 +1599,33 @@ void ApplicationController::SelectConversation(const QString& conversation_id) {
});
state_.local_messages[selected_conversation_id_] = rebuilt;
+ if (EnvFlagEnabled("BLACKWIRE_ENABLE_READ_CURSOR_V03B", true)) {
+ try {
+ const auto read_state_op = [this]() {
+ return api_client_.GetConversationReadState(
+ state_.base_url,
+ RequireAccessToken(),
+ selected_conversation_id_);
+ };
+ const auto read_state = CallWithAuthRetryOnce(
+ read_state_op,
+ [this]() { RefreshAccessToken(); });
+ auto& cursor_map = read_cursors_by_conversation_[selected_conversation_id_];
+ cursor_map.clear();
+ for (const auto& cursor : read_state.cursors) {
+ MergeReadCursor(
+ selected_conversation_id_,
+ QString::fromStdString(cursor.user_address),
+ QString::fromStdString(cursor.last_read_message_id),
+ cursor.last_read_sent_at_ms,
+ QString::fromStdString(cursor.updated_at));
+ }
+ } catch (const ApiException& ex) {
+ if (ex.status_code() != 404) {
+ throw;
+ }
+ }
+ }
if (!rebuilt.empty()) {
const auto& last = rebuilt.back();
const QString preview = last.plaintext.empty()
@@ -1432,8 +1639,10 @@ void ApplicationController::SelectConversation(const QString& conversation_id) {
QString::fromStdString(last.created_at));
}
+ PublishReadCursorForConversation(selected_conversation_id_);
PersistState();
RefreshConversationList();
+ emit TypingIndicatorChanged(conversation_id, BuildTypingIndicatorText(selected_conversation_id_));
emit ConversationSelected(conversation_id, RenderThread(selected_conversation_id_));
} catch (const std::exception& ex) {
const QString line = QString("Select conversation failed: %1").arg(ex.what());
@@ -2197,9 +2406,32 @@ void ApplicationController::SendMessageToPeer(const QString& peer_username, cons
local.sender_user_id = state_.user.id;
local.sender_address = self_address;
local.created_at = sent.message.created_at;
+ local.sent_at_ms = sent.message.sent_at_ms;
local.rendered_text = RenderMessage(sent.message, message_text.toStdString()).toStdString();
local.plaintext = message_text.toStdString();
- state_.local_messages[selected_conversation_id_].push_back(local);
+ local.attachment_status = "success";
+ local.retry_payload.clear();
+ {
+ QString attachment_name;
+ QString attachment_mime_type;
+ QString attachment_media_kind;
+ if (ParseAttachmentEnvelope(message_text, &attachment_name, &attachment_mime_type, &attachment_media_kind)) {
+ local.attachment_name = attachment_name.toStdString();
+ local.attachment_mime_type = attachment_mime_type.toStdString();
+ local.attachment_media_kind = attachment_media_kind.toStdString();
+ }
+ }
+ auto& thread_messages = state_.local_messages[selected_conversation_id_];
+ const std::string retry_payload = message_text.toStdString();
+ thread_messages.erase(
+ std::remove_if(
+ thread_messages.begin(),
+ thread_messages.end(),
+ [&retry_payload](const LocalMessage& item) {
+ return item.attachment_status == "sending" && item.retry_payload == retry_payload;
+ }),
+ thread_messages.end());
+ thread_messages.push_back(local);
state_.last_verified_chain_hash_by_conversation_sender[sender_chain_key] = sent.message.sender_chain_hash;
if (resolved_conversation_id != conversation_id) {
const std::string canonical_chain_key = resolved_conversation_id + "|" + state_.device.id;
@@ -2231,6 +2463,7 @@ void ApplicationController::SendMessageToPeer(const QString& peer_username, cons
PersistState();
RefreshConversationList();
+ PublishReadCursorForConversation(selected_conversation_id_);
emit MessageSendSucceeded(
QString::fromStdString(selected_conversation_id_),
QString::fromStdString(sent.message.id));
@@ -2240,6 +2473,45 @@ void ApplicationController::SendMessageToPeer(const QString& peer_username, cons
} catch (const std::exception& ex) {
const QString line = QString("Send message failed: %1").arg(ex.what());
RecordDiagnostic(line);
+ if (message_text.startsWith(kFileMessagePrefix, Qt::CaseInsensitive) && !selected_conversation_id_.empty()) {
+ const std::string retry_payload = message_text.toStdString();
+ auto& thread_messages = state_.local_messages[selected_conversation_id_];
+ auto pending_it = std::find_if(
+ thread_messages.begin(),
+ thread_messages.end(),
+ [&retry_payload](const LocalMessage& item) {
+ return item.attachment_status == "sending" && item.retry_payload == retry_payload;
+ });
+ if (pending_it != thread_messages.end()) {
+ pending_it->attachment_status = "failed";
+ } else {
+ LocalMessage failed;
+ failed.id = QString("local-failed-%1").arg(QUuid::createUuid().toString(QUuid::WithoutBraces)).toStdString();
+ failed.conversation_id = selected_conversation_id_;
+ failed.sender_user_id = state_.user.id;
+ failed.sender_address = UserDisplayId().toStdString();
+ failed.created_at = QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs).toStdString();
+ failed.sent_at_ms = QDateTime::currentMSecsSinceEpoch();
+ failed.rendered_text = message_text.toStdString();
+ failed.plaintext = message_text.toStdString();
+ failed.attachment_status = "failed";
+ failed.retry_payload = retry_payload;
+ QString attachment_name;
+ QString attachment_mime_type;
+ QString attachment_media_kind;
+ if (ParseAttachmentEnvelope(message_text, &attachment_name, &attachment_mime_type, &attachment_media_kind)) {
+ failed.attachment_name = attachment_name.toStdString();
+ failed.attachment_mime_type = attachment_mime_type.toStdString();
+ failed.attachment_media_kind = attachment_media_kind.toStdString();
+ }
+ thread_messages.push_back(std::move(failed));
+ }
+ PersistState();
+ RefreshConversationList();
+ emit ConversationSelected(
+ QString::fromStdString(selected_conversation_id_),
+ RenderThread(selected_conversation_id_));
+ }
emit ErrorOccurred(line);
}
}
@@ -2335,9 +2607,15 @@ void ApplicationController::SendFileToPeer(const QString& peer_username, const Q
}
const QFileInfo info(normalized_path);
+ const QMimeDatabase mime_db;
+ const QString mime_type = mime_db.mimeTypeForFile(info).name().trimmed().toLower();
QJsonObject payload;
payload.insert("name", info.fileName());
payload.insert("size", static_cast(bytes.size()));
+ payload.insert("mime_type", mime_type.isEmpty() ? "application/octet-stream" : mime_type);
+ payload.insert(
+ "media_kind",
+ MediaKindFromMime(mime_type.isEmpty() ? "application/octet-stream" : mime_type));
payload.insert("data_b64", QString::fromLatin1(bytes.toBase64()));
const QByteArray serialized = QJsonDocument(payload).toJson(QJsonDocument::Compact);
const QString marker =
@@ -2354,6 +2632,28 @@ void ApplicationController::SendFileToPeer(const QString& peer_username, const Q
return;
}
+ if (!selected_conversation_id_.empty()) {
+ LocalMessage pending;
+ pending.id = QString("local-attachment-%1").arg(QUuid::createUuid().toString(QUuid::WithoutBraces)).toStdString();
+ pending.conversation_id = selected_conversation_id_;
+ pending.sender_user_id = state_.user.id;
+ pending.sender_address = UserDisplayId().toStdString();
+ pending.created_at = QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs).toStdString();
+ pending.sent_at_ms = QDateTime::currentMSecsSinceEpoch();
+ pending.rendered_text = marker.toStdString();
+ pending.plaintext = marker.toStdString();
+ pending.attachment_name = info.fileName().toStdString();
+ pending.attachment_mime_type = mime_type.toStdString();
+ pending.attachment_media_kind = MediaKindFromMime(mime_type).toStdString();
+ pending.attachment_status = "sending";
+ pending.retry_payload = marker.toStdString();
+ state_.local_messages[selected_conversation_id_].push_back(std::move(pending));
+ PersistState();
+ emit ConversationSelected(
+ QString::fromStdString(selected_conversation_id_),
+ RenderThread(selected_conversation_id_));
+ }
+
SendMessageToPeer(selected_group_conversation ? QString() : normalized_peer, marker);
}
@@ -2639,6 +2939,18 @@ bool ApplicationController::AcceptMessagesFromStrangers() const {
return state_.social_preferences.accept_messages_from_strangers;
}
+bool ApplicationController::SaveMessageCache() const {
+ return state_.social_preferences.save_message_cache;
+}
+
+void ApplicationController::SetSaveMessageCache(bool enabled) {
+ if (state_.social_preferences.save_message_cache == enabled) {
+ return;
+ }
+ state_.social_preferences.save_message_cache = enabled;
+ PersistState();
+}
+
void ApplicationController::SetAcceptMessagesFromStrangers(bool enabled) {
if (state_.social_preferences.accept_messages_from_strangers == enabled) {
return;
@@ -2685,6 +2997,114 @@ void ApplicationController::IgnoreMessageRequest(const QString& conversation_id)
RefreshConversationList();
}
+void ApplicationController::PublishTypingState(const QString& conversation_id, bool typing) {
+ const std::string key = conversation_id.trimmed().toStdString();
+ if (key.empty() || !state_.has_user || !state_.has_device) {
+ return;
+ }
+ if (!EnvFlagEnabled("BLACKWIRE_ENABLE_TYPING_V03B", true)) {
+ return;
+ }
+ const bool previous = local_typing_state_by_conversation_[key];
+ if (previous == typing) {
+ return;
+ }
+ local_typing_state_by_conversation_[key] = typing;
+
+ try {
+ ConversationTypingRequest request;
+ request.state = typing ? "on" : "off";
+ request.client_ts_ms = QDateTime::currentMSecsSinceEpoch();
+ const auto op = [this, &key, &request]() {
+ return api_client_.SendConversationTyping(
+ state_.base_url,
+ RequireAccessToken(),
+ key,
+ request);
+ };
+ (void)CallWithAuthRetryOnce(op, [this]() { RefreshAccessToken(); });
+ } catch (const ApiException& ex) {
+ if (ex.status_code() == 404) {
+ return;
+ }
+ RecordDiagnostic(QString("typing publish failed: %1").arg(QString::fromStdString(ex.what())));
+ } catch (const std::exception& ex) {
+ RecordDiagnostic(QString("typing publish failed: %1").arg(ex.what()));
+ }
+}
+
+void ApplicationController::RetryFailedAttachment(const QString& message_id) {
+ const std::string local_id = message_id.trimmed().toStdString();
+ if (local_id.empty() || selected_conversation_id_.empty()) {
+ return;
+ }
+ auto messages_it = state_.local_messages.find(selected_conversation_id_);
+ if (messages_it == state_.local_messages.end()) {
+ return;
+ }
+
+ std::string retry_payload;
+ LocalMessage retry_template;
+ bool found = false;
+ for (const auto& message : messages_it->second) {
+ if (message.id != local_id) {
+ continue;
+ }
+ if (message.retry_payload.empty()) {
+ return;
+ }
+ retry_payload = message.retry_payload;
+ retry_template = message;
+ found = true;
+ break;
+ }
+ if (!found || retry_payload.empty()) {
+ return;
+ }
+
+ messages_it->second.erase(
+ std::remove_if(
+ messages_it->second.begin(),
+ messages_it->second.end(),
+ [&local_id](const LocalMessage& message) { return message.id == local_id; }),
+ messages_it->second.end());
+ retry_template.id = QString("local-attachment-%1").arg(QUuid::createUuid().toString(QUuid::WithoutBraces)).toStdString();
+ retry_template.created_at = QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs).toStdString();
+ retry_template.sent_at_ms = QDateTime::currentMSecsSinceEpoch();
+ retry_template.attachment_status = "sending";
+ retry_template.retry_payload = retry_payload;
+ messages_it->second.push_back(std::move(retry_template));
+ PersistState();
+ emit ConversationSelected(
+ QString::fromStdString(selected_conversation_id_),
+ RenderThread(selected_conversation_id_));
+
+ SendMessageToPeer(QString(), QString::fromStdString(retry_payload));
+}
+
+void ApplicationController::LoadSystemVersion() {
+ if (!state_.has_user || !state_.has_device) {
+ server_version_ = "unknown";
+ return;
+ }
+ try {
+ const auto op = [this]() {
+ return api_client_.GetSystemVersion(state_.base_url, RequireAccessToken());
+ };
+ const auto response = CallWithAuthRetryOnce(op, [this]() { RefreshAccessToken(); });
+ server_version_ = QString::fromStdString(response.server_version).trimmed();
+ if (server_version_.isEmpty()) {
+ server_version_ = "unknown";
+ }
+ RecordDiagnostic(
+ QString("server_version=%1 git_commit=%2")
+ .arg(server_version_, QString::fromStdString(response.git_commit)));
+ } catch (const std::exception& ex) {
+ server_version_ = "unknown";
+ RecordDiagnostic(QString("system version fetch failed: %1").arg(ex.what()));
+ }
+}
+
void ApplicationController::ResetLocalState() {
std::string error;
StopAudioEngine();
@@ -2758,13 +3178,18 @@ QString ApplicationController::ConnectionStatus() const {
return connection_status_;
}
+QString ApplicationController::ClientVersion() const {
+ return client_version_;
+}
+
+QString ApplicationController::ServerVersion() const {
+ return server_version_;
+}
+
QString ApplicationController::DiagnosticsReport() const {
- const char* version = "0.1.0";
-#ifdef BLACKWIRE_CLIENT_VERSION
- version = BLACKWIRE_CLIENT_VERSION;
-#endif
QStringList lines;
- lines << QString("client_version=%1").arg(version);
+ lines << QString("client_version=%1").arg(client_version_);
+ lines << QString("server_version=%1").arg(server_version_);
lines << QString("profile=%1").arg(QString::fromStdString(profile_name_));
lines << QString("timestamp=%1").arg(QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs));
lines << QString("base_url=%1").arg(BaseUrl());
@@ -2926,7 +3351,15 @@ void ApplicationController::DecryptPlaintextCacheInState() {
void ApplicationController::PersistState() {
EncryptPlaintextCacheInState();
- state_store_.Save(state_);
+ if (!state_.social_preferences.save_message_cache) {
+ // User opted out of message caching; strip persisted message data.
+ // Runtime state_.local_messages is kept intact for the current session.
+ ClientState save_copy = state_;
+ save_copy.local_messages.clear();
+ state_store_.Save(save_copy);
+ } else {
+ state_store_.Save(state_);
+ }
}
QString ApplicationController::NormalizePresenceStatus(const QString& status) const {
@@ -3026,6 +3459,8 @@ void ApplicationController::RefreshPresenceCache() {
void ApplicationController::RefreshConversationList() {
std::vector items;
items.reserve(state_.conversations.size());
+ const QString normalized_call_state = call_state_.state.trimmed().toLower();
+ const QString call_conversation_id = call_state_.conversation_id.trimmed();
for (const auto& conv : state_.conversations) {
if (state_.blocked_conversation_ids.contains(conv.id)) {
@@ -3053,6 +3488,17 @@ void ApplicationController::RefreshConversationList() {
} else {
item.subtitle = "(no messages yet)";
}
+ if (item.conversation_type == "group" &&
+ item.id == call_conversation_id &&
+ (normalized_call_state == "incoming_ringing" ||
+ normalized_call_state == "outgoing_ringing" ||
+ normalized_call_state == "active")) {
+ if (normalized_call_state == "active") {
+ item.subtitle = "Open call in progress - join from this group";
+ } else {
+ item.subtitle = "Open call available - click to join";
+ }
+ }
item.last_activity_at = LastActivityForConversation(conv.id);
item.status = PresenceForPeerAddress(ResolvePeerAddressForConversation(conv.id));
@@ -3094,7 +3540,186 @@ std::vector ApplicationController::RenderThread(const std::st
}
}
- return BuildThreadMessageViews(iter->second, state_.user.id, peer_label);
+ auto views = BuildThreadMessageViews(iter->second, state_.user.id, peer_label);
+ const QString self_address = UserDisplayId().trimmed().toLower();
+ long long max_other_read_sent_at_ms = 0;
+ const auto cursor_it = read_cursors_by_conversation_.find(conversation_id);
+ if (cursor_it != read_cursors_by_conversation_.end()) {
+ for (const auto& [address, cursor] : cursor_it->second) {
+ const QString normalized = QString::fromStdString(address).trimmed().toLower();
+ if (!self_address.isEmpty() && normalized == self_address) {
+ continue;
+ }
+ max_other_read_sent_at_ms = std::max(max_other_read_sent_at_ms, cursor.last_read_sent_at_ms);
+ }
+ }
+ for (auto& view : views) {
+ if (!view.outgoing) {
+ view.delivery_badge.clear();
+ continue;
+ }
+ if (view.attachment_status.trimmed().toLower() == "failed") {
+ view.delivery_badge = "Failed";
+ continue;
+ }
+ if (view.sent_at_ms > 0 && max_other_read_sent_at_ms >= view.sent_at_ms) {
+ view.delivery_badge = "Seen";
+ } else {
+ view.delivery_badge = "Sent";
+ }
+ }
+ return views;
+}
+
+QString ApplicationController::BuildTypingIndicatorText(const std::string& conversation_id) const {
+ const auto typing_it = typing_expiry_ms_by_conversation_.find(conversation_id);
+ if (typing_it == typing_expiry_ms_by_conversation_.end()) {
+ return {};
+ }
+ const qint64 now_ms = QDateTime::currentMSecsSinceEpoch();
+ QStringList active_users;
+ for (const auto& [address, expiry_ms] : typing_it->second) {
+ if (expiry_ms <= now_ms) {
+ continue;
+ }
+ QString username = QString::fromStdString(address).trimmed();
+ if (username.contains('@')) {
+ username = username.section('@', 0, 0).trimmed();
+ }
+ if (username.isEmpty()) {
+ username = "Someone";
+ }
+ if (!active_users.contains(username)) {
+ active_users.push_back(username);
+ }
+ }
+ if (active_users.isEmpty()) {
+ return {};
+ }
+ if (active_users.size() == 1) {
+ return QString("%1 is typing...").arg(active_users.front());
+ }
+ if (active_users.size() == 2) {
+ return QString("%1 and %2 are typing...").arg(active_users[0], active_users[1]);
+ }
+ return "Several people are typing...";
+}
+
+void ApplicationController::PruneExpiredTypingIndicators() {
+ const qint64 now_ms = QDateTime::currentMSecsSinceEpoch();
+ bool selected_changed = false;
+ for (auto conv_it = typing_expiry_ms_by_conversation_.begin(); conv_it != typing_expiry_ms_by_conversation_.end();) {
+ auto& by_sender = conv_it->second;
+ for (auto sender_it = by_sender.begin(); sender_it != by_sender.end();) {
+ if (sender_it->second <= now_ms) {
+ sender_it = by_sender.erase(sender_it);
+ } else {
+ ++sender_it;
+ }
+ }
+ if (by_sender.empty()) {
+ if (selected_conversation_id_ == conv_it->first) {
+ selected_changed = true;
+ }
+ conv_it = typing_expiry_ms_by_conversation_.erase(conv_it);
+ } else {
+ ++conv_it;
+ }
+ }
+ if (selected_changed && !selected_conversation_id_.empty()) {
+ emit TypingIndicatorChanged(
+ QString::fromStdString(selected_conversation_id_),
+ BuildTypingIndicatorText(selected_conversation_id_));
+ }
+}
+
+bool ApplicationController::PublishReadCursorForConversation(const std::string& conversation_id) {
+ if (conversation_id.empty() || !state_.has_user || !state_.has_device) {
+ return false;
+ }
+ if (!EnvFlagEnabled("BLACKWIRE_ENABLE_READ_CURSOR_V03B", true)) {
+ return false;
+ }
+ const auto thread_it = state_.local_messages.find(conversation_id);
+ if (thread_it == state_.local_messages.end() || thread_it->second.empty()) {
+ return false;
+ }
+ const LocalMessage* latest = nullptr;
+ for (auto it = thread_it->second.rbegin(); it != thread_it->second.rend(); ++it) {
+ if (it->sent_at_ms > 0) {
+ latest = &(*it);
+ break;
+ }
+ }
+ if (latest == nullptr) {
+ return false;
+ }
+ const long long last_sent_at = latest->sent_at_ms;
+ const auto last_it = last_published_read_sent_at_by_conversation_.find(conversation_id);
+ if (last_it != last_published_read_sent_at_by_conversation_.end() && last_it->second >= last_sent_at) {
+ return false;
+ }
+
+ try {
+ ConversationReadRequest request;
+ request.last_read_message_id = latest->id;
+ request.last_read_sent_at_ms = last_sent_at;
+ const auto op = [this, &conversation_id, &request]() {
+ return api_client_.SendConversationRead(
+ state_.base_url,
+ RequireAccessToken(),
+ conversation_id,
+ request);
+ };
+ const auto cursor = CallWithAuthRetryOnce(op, [this]() { RefreshAccessToken(); });
+ MergeReadCursor(
+ conversation_id,
+ QString::fromStdString(cursor.reader_user_address),
+ QString::fromStdString(cursor.last_read_message_id),
+ cursor.last_read_sent_at_ms,
+ QString::fromStdString(cursor.updated_at));
+ last_published_read_sent_at_by_conversation_[conversation_id] = cursor.last_read_sent_at_ms;
+ return true;
+ } catch (const ApiException& ex) {
+ if (ex.status_code() == 404) {
+ return false;
+ }
+ RecordDiagnostic(QString("read cursor publish failed: %1").arg(QString::fromStdString(ex.what())));
+ return false;
+ } catch (const std::exception& ex) {
+ RecordDiagnostic(QString("read cursor publish failed: %1").arg(ex.what()));
+ return false;
+ }
+}
+
+void ApplicationController::MergeReadCursor(
+ const std::string& conversation_id,
+ const QString& reader_user_address,
+ const QString& last_read_message_id,
+ long long last_read_sent_at_ms,
+ const QString& updated_at) {
+ if (conversation_id.empty()) {
+ return;
+ }
+ const QString normalized_reader = reader_user_address.trimmed().toLower();
+ if (normalized_reader.isEmpty()) {
+ return;
+ }
+
+ auto& by_reader = read_cursors_by_conversation_[conversation_id];
+ auto& state = by_reader[normalized_reader.toStdString()];
+ if (last_read_sent_at_ms < state.last_read_sent_at_ms) {
+ return;
+ }
+ state.last_read_message_id = last_read_message_id.trimmed();
+ state.last_read_sent_at_ms = last_read_sent_at_ms;
+ state.updated_at = updated_at.trimmed();
+
+ if (normalized_reader == UserDisplayId().trimmed().toLower()) {
+ last_published_read_sent_at_by_conversation_[conversation_id] = std::max(
+ last_published_read_sent_at_by_conversation_[conversation_id],
+ last_read_sent_at_ms);
+ }
}
std::optional ApplicationController::NormalizePeerUsername(const QString& value, QString* error) const {
@@ -3451,8 +4076,10 @@ void ApplicationController::AppendCallHistoryEntry(const QString& reason) {
local.sender_user_id.clear();
local.sender_address.clear();
local.created_at = QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs).toStdString();
+ local.sent_at_ms = QDateTime::currentMSecsSinceEpoch();
local.rendered_text = text.toStdString();
local.plaintext = text.toStdString();
+ local.attachment_status = "success";
state_.local_messages[conversation_id.toStdString()].push_back(local);
UpsertConversationMeta(
@@ -3499,10 +4126,12 @@ void ApplicationController::AppendGroupRenameHistoryEntry(
local.id = synthetic_id;
local.conversation_id = conversation.toStdString();
local.sender_user_id.clear();
- local.sender_address = actor_address.trimmed().toLower().toStdString();
+ local.sender_address.clear();
local.created_at = QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs).toStdString();
+ local.sent_at_ms = QDateTime::currentMSecsSinceEpoch();
local.rendered_text = text.toStdString();
local.plaintext = text.toStdString();
+ local.attachment_status = "success";
state_.local_messages[conversation.toStdString()].push_back(local);
UpsertConversationMeta(
@@ -3580,6 +4209,7 @@ void ApplicationController::TransitionCallState(
}
EmitCallState();
+ RefreshConversationList();
}
bool ApplicationController::StartAudioEngineForActiveCall(QString* warning, QString* error) {
@@ -3807,10 +4437,15 @@ void ApplicationController::ClearInMemoryState() {
peer_attachment_policy_cache_.clear();
local_attachment_policy_cache_.reset();
peer_presence_status_by_address_.clear();
+ read_cursors_by_conversation_.clear();
+ last_published_read_sent_at_by_conversation_.clear();
+ typing_expiry_ms_by_conversation_.clear();
+ local_typing_state_by_conversation_.clear();
pending_request_messages_.clear();
pending_request_senders_.clear();
selected_conversation_id_.clear();
connection_status_ = "Disconnected";
+ server_version_ = "unknown";
user_presence_status_ = "active";
call_state_ = CallStateView{};
call_initiated_locally_ = false;
diff --git a/client-cpp-gui/src/smoke/smoke_runner.cpp b/client-cpp-gui/src/smoke/smoke_runner.cpp
index 58b7378..1abd282 100644
--- a/client-cpp-gui/src/smoke/smoke_runner.cpp
+++ b/client-cpp-gui/src/smoke/smoke_runner.cpp
@@ -159,6 +159,8 @@ int SmokeRunner::Run(const QString& base_url) {
[&](const WsEventCallWebRtcAnswer&) {},
[&](const WsEventCallWebRtcIce&) {},
[&](const WsEventGroupRenamed&) {},
+ [&](const WsEventConversationTyping&) {},
+ [&](const WsEventConversationRead&) {},
[&](const std::string& error) {
std::cerr << "WS error: " << error << '\n';
},
diff --git a/client-cpp-gui/src/ui/chat_widget.cpp b/client-cpp-gui/src/ui/chat_widget.cpp
index b11046c..5a640bb 100644
--- a/client-cpp-gui/src/ui/chat_widget.cpp
+++ b/client-cpp-gui/src/ui/chat_widget.cpp
@@ -6,6 +6,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -19,17 +20,25 @@
#include
#include
#include
+#include
+#include
#include
#include
+#include
+#include
#include
#include
#include
#include
+#include
#include
#include
#include
#include
#include
+#include
+#include
+#include
#include
#include
@@ -62,15 +71,15 @@ QString NormalizePresenceStatus(const QString& value) {
QString PresenceColorForStatus(const QString& value) {
const QString normalized = NormalizePresenceStatus(value);
if (normalized == "active") {
- return "#2d7d46"; // green
+ return "#23a55a"; // Discord green
}
if (normalized == "inactive") {
- return "#d4a63c"; // yellow
+ return "#f0b232"; // Discord yellow/idle
}
if (normalized == "dnd") {
- return "#9e2c31"; // red
+ return "#f23f43"; // Discord red
}
- return "#6a6f78"; // offline/default gray
+ return "#80848e"; // Discord offline gray
}
QString HumanFileSize(qint64 size_bytes) {
@@ -87,7 +96,9 @@ bool DecodeFileMessageBody(
const QString& body,
QString* file_name,
QByteArray* file_bytes,
- qint64* file_size_bytes) {
+ qint64* file_size_bytes,
+ QString* mime_type,
+ QString* media_kind) {
if (!body.startsWith(kFileMessagePrefix, Qt::CaseInsensitive)) {
return false;
}
@@ -125,9 +136,33 @@ bool DecodeFileMessageBody(
if (file_size_bytes != nullptr) {
*file_size_bytes = static_cast(obj.value("size").toDouble(static_cast(bytes.size())));
}
+ if (mime_type != nullptr) {
+ *mime_type = obj.value("mime_type").toString().trimmed().toLower();
+ }
+ if (media_kind != nullptr) {
+ QString kind = obj.value("media_kind").toString().trimmed().toLower();
+ if (kind.isEmpty()) {
+ const QString resolved_mime = obj.value("mime_type").toString().trimmed().toLower();
+ if (resolved_mime.startsWith("image/")) {
+ kind = "image";
+ } else if (resolved_mime.startsWith("video/")) {
+ kind = "video";
+ } else {
+ kind = "file";
+ }
+ }
+ *media_kind = kind;
+ }
return true;
}
+QString SanitizeMarkdownSource(QString source) {
+ source.replace("&", "&");
+ source.replace("<", "<");
+ source.replace(">", ">");
+ return source;
+}
+
QString FormatCallStatusText(const CallStateView& state) {
const QString normalized = state.state.trimmed().toLower();
if (normalized == "active") {
@@ -221,33 +256,49 @@ ChatWidget::ChatWidget(QWidget* parent) : QWidget(parent) {
auto* sidebar = new QWidget(split);
sidebar->setObjectName("dmSidebar");
- sidebar->setMinimumWidth(290);
- sidebar->setMaximumWidth(360);
+ sidebar->setMinimumWidth(240);
+ sidebar->setMaximumWidth(320);
auto* sidebar_layout = new QVBoxLayout(sidebar);
- sidebar_layout->setContentsMargins(14, 14, 14, 14);
- sidebar_layout->setSpacing(10);
+ sidebar_layout->setContentsMargins(0, 12, 0, 0);
+ sidebar_layout->setSpacing(6);
- auto* sidebar_title = new QLabel("Messages", sidebar);
+ auto* sidebar_title = new QLabel("DIRECT MESSAGES", sidebar);
sidebar_title->setObjectName("dmSidebarTitle");
sidebar_layout->addWidget(sidebar_title);
auto* contacts_toggle_card = new QWidget(sidebar);
contacts_toggle_card->setObjectName("contactsToggleCard");
auto* contacts_toggle_layout = new QHBoxLayout(contacts_toggle_card);
- contacts_toggle_layout->setContentsMargins(10, 8, 10, 8);
+ contacts_toggle_layout->setContentsMargins(8, 4, 8, 8);
contacts_toggle_layout->setSpacing(0);
- contacts_button_ = new QPushButton("Contacts", contacts_toggle_card);
+ contacts_button_ = new QPushButton("Friends", contacts_toggle_card);
contacts_button_->setObjectName("secondaryButton");
contacts_button_->setCheckable(true);
+ contacts_button_->setStyleSheet(
+ "QPushButton { background: transparent; border: none; color: #949ba4;"
+ " font-size: 14px; font-weight: 500; padding: 8px 12px; border-radius: 4px;"
+ " text-align: left; }"
+ "QPushButton:hover { background: #35373c; color: #dbdee1; }"
+ "QPushButton:checked { background: #404249; color: #ffffff; }");
contacts_toggle_layout->addWidget(contacts_button_);
sidebar_layout->addWidget(contacts_toggle_card);
auto* peer_row = new QHBoxLayout();
- peer_row->setSpacing(8);
+ peer_row->setContentsMargins(8, 0, 8, 0);
+ peer_row->setSpacing(6);
peer_input_ = new QLineEdit(sidebar);
- peer_input_->setPlaceholderText("peer username or username@onion");
- new_chat_button_ = new QPushButton("Open DM", sidebar);
+ peer_input_->setPlaceholderText("Find or start a conversation");
+ peer_input_->setStyleSheet(
+ "QLineEdit { background: #1e1f22; border-radius: 4px; padding: 6px 8px;"
+ " font-size: 13px; color: #949ba4; }");
+ new_chat_button_ = new QPushButton("+", sidebar);
new_chat_button_->setObjectName("secondaryButton");
+ new_chat_button_->setFixedSize(28, 28);
+ new_chat_button_->setStyleSheet(
+ "QPushButton { background: transparent; border: none; color: #b5bac1;"
+ " font-size: 18px; font-weight: 700; border-radius: 4px; padding: 0; }"
+ "QPushButton:hover { color: #dbdee1; background: #35373c; }");
+ new_chat_button_->setToolTip("Open DM");
peer_row->addWidget(peer_input_, 1);
peer_row->addWidget(new_chat_button_);
sidebar_layout->addLayout(peer_row);
@@ -262,41 +313,75 @@ ChatWidget::ChatWidget(QWidget* parent) : QWidget(parent) {
auto* identity_card = new QWidget(sidebar);
identity_card->setObjectName("identityCard");
- auto* identity_layout = new QVBoxLayout(identity_card);
- identity_layout->setContentsMargins(10, 10, 10, 10);
- identity_layout->setSpacing(6);
-
+ auto* identity_layout = new QHBoxLayout(identity_card);
+ identity_layout->setContentsMargins(8, 6, 8, 6);
+ identity_layout->setSpacing(8);
+
+ auto* identity_avatar = new QLabel("U", identity_card);
+ identity_avatar->setFixedSize(32, 32);
+ identity_avatar->setAlignment(Qt::AlignCenter);
+ identity_avatar->setStyleSheet(
+ "background: #5865f2; border-radius: 16px; color: #ffffff;"
+ " font-size: 13px; font-weight: 600;");
+ identity_layout->addWidget(identity_avatar);
+
+ auto* identity_text_col = new QVBoxLayout();
+ identity_text_col->setSpacing(0);
identity_label_ = new QLabel("Your ID: -", identity_card);
identity_label_->setObjectName("identityLabel");
identity_label_->setWordWrap(true);
- identity_layout->addWidget(identity_label_);
+ identity_text_col->addWidget(identity_label_);
+ identity_layout->addLayout(identity_text_col, 1);
- auto* identity_actions = new QHBoxLayout();
- identity_actions->setSpacing(8);
- copy_identity_button_ = new QPushButton("Copy ID", identity_card);
+ copy_identity_button_ = new QPushButton("Copy", identity_card);
copy_identity_button_->setObjectName("secondaryButton");
copy_identity_button_->setEnabled(false);
+ copy_identity_button_->setFixedHeight(24);
+ copy_identity_button_->setStyleSheet(
+ "QPushButton { background: transparent; border: none; color: #949ba4;"
+ " font-size: 12px; padding: 2px 6px; border-radius: 3px; }"
+ "QPushButton:hover { background: #35373c; color: #dbdee1; }");
settings_button_ = new QPushButton("Settings", identity_card);
settings_button_->setObjectName("secondaryButton");
- identity_actions->addWidget(copy_identity_button_);
- identity_actions->addWidget(settings_button_);
- identity_layout->addLayout(identity_actions);
+ settings_button_->setFixedHeight(24);
+ settings_button_->setStyleSheet(
+ "QPushButton { background: transparent; border: none; color: #949ba4;"
+ " font-size: 12px; padding: 2px 6px; border-radius: 3px; }"
+ "QPushButton:hover { background: #35373c; color: #dbdee1; }");
+ identity_layout->addWidget(copy_identity_button_);
+ identity_layout->addWidget(settings_button_);
sidebar_layout->addWidget(identity_card);
auto* content = new QWidget(split);
content->setObjectName("chatPane");
auto* content_layout = new QVBoxLayout(content);
- content_layout->setContentsMargins(16, 16, 16, 16);
- content_layout->setSpacing(10);
-
- auto* header_row = new QHBoxLayout();
+ content_layout->setContentsMargins(0, 0, 0, 0);
+ content_layout->setSpacing(0);
+
+ /* ── Discord-style header bar ─────────────────────────── */
+ auto* header_bar = new QWidget(content);
+ header_bar->setObjectName("chatHeaderBar");
+ header_bar->setFixedHeight(48);
+ auto* header_row = new QHBoxLayout(header_bar);
+ header_row->setContentsMargins(16, 0, 16, 0);
header_row->setSpacing(8);
+
+ auto* channel_hash = new QLabel("@", content);
+ channel_hash->setStyleSheet("color: #949ba4; font-size: 20px; font-weight: 600; background: transparent;");
+ channel_hash->setFixedWidth(20);
+
thread_title_label_ = new QLineEdit("Select a conversation", content);
thread_title_label_->setObjectName("threadTitle");
thread_title_label_->setReadOnly(true);
thread_title_label_->setFrame(false);
thread_title_label_->setFocusPolicy(Qt::NoFocus);
thread_title_label_->setCursor(Qt::ArrowCursor);
+
+ /* Divider between title and actions */
+ auto* header_divider = new QWidget(content);
+ header_divider->setFixedSize(1, 24);
+ header_divider->setStyleSheet("background: #3f4147;");
+
call_button_ = new QPushButton("Call", content);
call_button_->setObjectName("primaryButton");
group_button_ = new QPushButton("Group", content);
@@ -316,14 +401,17 @@ ChatWidget::ChatWidget(QWidget* parent) : QWidget(parent) {
status_label_ = new QLabel("Disconnected", content);
status_label_->setObjectName("connectionPill");
status_label_->setProperty("state", "disconnected");
+
+ header_row->addWidget(channel_hash);
header_row->addWidget(thread_title_label_, 1);
+ header_row->addWidget(header_divider);
header_row->addWidget(call_button_);
header_row->addWidget(group_button_);
header_row->addWidget(invite_button_);
header_row->addWidget(presence_indicator_);
header_row->addWidget(presence_combo_);
header_row->addWidget(status_label_);
- content_layout->addLayout(header_row);
+ content_layout->addWidget(header_bar);
banner_label_ = new QLabel(content);
banner_label_->setObjectName("chatBanner");
@@ -336,7 +424,7 @@ ChatWidget::ChatWidget(QWidget* parent) : QWidget(parent) {
chat_panel_ = new QWidget(content_stack_);
auto* chat_layout = new QVBoxLayout(chat_panel_);
chat_layout->setContentsMargins(0, 0, 0, 0);
- chat_layout->setSpacing(10);
+ chat_layout->setSpacing(0);
call_panel_ = new QWidget(chat_panel_);
call_panel_->setObjectName("callPanel");
@@ -400,32 +488,54 @@ ChatWidget::ChatWidget(QWidget* parent) : QWidget(parent) {
messages_list_->setSelectionMode(QAbstractItemView::NoSelection);
messages_list_->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
messages_list_->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
- messages_list_->setSpacing(4);
+ messages_list_->setSpacing(0);
timeline_stack_->addWidget(empty_state_label_);
timeline_stack_->addWidget(messages_list_);
timeline_stack_->setCurrentWidget(empty_state_label_);
chat_layout->addWidget(timeline_stack_, 1);
- auto* compose_row = new QHBoxLayout();
- compose_row->setSpacing(10);
- compose_input_ = new QPlainTextEdit(chat_panel_);
+ typing_indicator_label_ = new QLabel(chat_panel_);
+ typing_indicator_label_->setObjectName("conversationSubtitle");
+ typing_indicator_label_->setWordWrap(true);
+ typing_indicator_label_->setVisible(false);
+ typing_indicator_label_->setContentsMargins(16, 4, 16, 2);
+ chat_layout->addWidget(typing_indicator_label_);
+
+ auto* compose_container = new QWidget(chat_panel_);
+ compose_container->setObjectName("chatPane");
+ auto* compose_outer = new QHBoxLayout(compose_container);
+ compose_outer->setContentsMargins(16, 0, 16, 16);
+ compose_outer->setSpacing(0);
+
+ auto* compose_bar = new QWidget(compose_container);
+ compose_bar->setStyleSheet("background: #383a40; border-radius: 8px;");
+ auto* compose_row = new QHBoxLayout(compose_bar);
+ compose_row->setContentsMargins(4, 4, 4, 4);
+ compose_row->setSpacing(4);
+ compose_input_ = new QPlainTextEdit(compose_bar);
compose_input_->setObjectName("composeInput");
- compose_input_->setPlaceholderText("Message #dm");
+ compose_input_->setPlaceholderText("Message #channel");
compose_input_->setMaximumBlockCount(200);
- compose_input_->setFixedHeight(96);
+ compose_input_->setFixedHeight(44);
compose_input_->installEventFilter(this);
- attach_button_ = new QPushButton("Attach", chat_panel_);
+ attach_button_ = new QPushButton("+", compose_bar);
attach_button_->setObjectName("secondaryButton");
- send_button_ = new QPushButton("Send", chat_panel_);
+ attach_button_->setFixedSize(32, 32);
+ attach_button_->setStyleSheet(
+ "QPushButton { background: transparent; border: none; color: #b5bac1;"
+ " font-size: 20px; font-weight: 700; border-radius: 4px; padding: 0; }"
+ "QPushButton:hover { color: #dbdee1; background: #4e5058; }");
+ send_button_ = new QPushButton("Send", compose_bar);
send_button_->setObjectName("primaryButton");
send_button_->setEnabled(false);
- compose_row->addWidget(compose_input_, 1);
compose_row->addWidget(attach_button_);
+ compose_row->addWidget(compose_input_, 1);
compose_row->addWidget(send_button_);
- chat_layout->addLayout(compose_row);
+ compose_outer->addWidget(compose_bar, 1);
+ chat_layout->addWidget(compose_container);
contacts_panel_ = new QWidget(content_stack_);
auto* contacts_layout = new QVBoxLayout(contacts_panel_);
@@ -458,6 +568,18 @@ ChatWidget::ChatWidget(QWidget* parent) : QWidget(parent) {
banner_label_->setVisible(false);
});
+ typing_idle_timer_ = new QTimer(this);
+ typing_idle_timer_->setSingleShot(true);
+ typing_idle_timer_->setInterval(1600);
+ connect(typing_idle_timer_, &QTimer::timeout, this, [this]() {
+ if (!local_typing_active_) {
+ return;
+ }
+ local_typing_active_ = false;
+ emit TypingStateChanged(local_typing_conversation_id_, false);
+ local_typing_conversation_id_.clear();
+ });
+
auto* focus_peer_shortcut = new QShortcut(QKeySequence("Ctrl+K"), this);
connect(focus_peer_shortcut, &QShortcut::activated, this, [this]() {
peer_input_->setFocus();
@@ -522,7 +644,31 @@ ChatWidget::ChatWidget(QWidget* parent) : QWidget(parent) {
});
connect(compose_input_, &QPlainTextEdit::textChanged, this, [this]() {
- SetSendEnabled(!ComposeText().trimmed().isEmpty());
+ const bool has_text = !ComposeText().trimmed().isEmpty();
+ SetSendEnabled(has_text);
+ const QString conversation_id = SelectedConversation();
+ if (conversation_id.isEmpty() || contacts_mode_active_) {
+ return;
+ }
+ if (has_text) {
+ if (!local_typing_active_) {
+ local_typing_active_ = true;
+ local_typing_conversation_id_ = conversation_id;
+ emit TypingStateChanged(conversation_id, true);
+ }
+ if (typing_idle_timer_ != nullptr) {
+ typing_idle_timer_->start();
+ }
+ return;
+ }
+ if (local_typing_active_) {
+ local_typing_active_ = false;
+ if (typing_idle_timer_ != nullptr) {
+ typing_idle_timer_->stop();
+ }
+ emit TypingStateChanged(local_typing_conversation_id_, false);
+ local_typing_conversation_id_.clear();
+ }
});
connect(presence_combo_, &QComboBox::currentIndexChanged, this, [this](int index) {
@@ -539,6 +685,14 @@ ChatWidget::ChatWidget(QWidget* parent) : QWidget(parent) {
});
connect(conversations_list_, &QListWidget::itemSelectionChanged, this, [this]() {
+ if (local_typing_active_) {
+ local_typing_active_ = false;
+ if (typing_idle_timer_ != nullptr) {
+ typing_idle_timer_->stop();
+ }
+ emit TypingStateChanged(local_typing_conversation_id_, false);
+ local_typing_conversation_id_.clear();
+ }
const QString id = SelectedConversation();
SyncContactSelection(id);
SetContactsMode(false);
@@ -556,6 +710,14 @@ ChatWidget::ChatWidget(QWidget* parent) : QWidget(parent) {
});
connect(contacts_panel_list_, &QListWidget::itemSelectionChanged, this, [this]() {
+ if (local_typing_active_) {
+ local_typing_active_ = false;
+ if (typing_idle_timer_ != nullptr) {
+ typing_idle_timer_->stop();
+ }
+ emit TypingStateChanged(local_typing_conversation_id_, false);
+ local_typing_conversation_id_.clear();
+ }
const auto* selected = contacts_panel_list_->currentItem();
if (selected == nullptr) {
return;
@@ -593,11 +755,24 @@ QString ChatWidget::SelectedConversation() const {
}
void ChatWidget::SetSelectedConversation(const QString& conversation_id) {
+ const QString previous_conversation = SelectedConversation();
+ if (local_typing_active_ && previous_conversation != conversation_id) {
+ local_typing_active_ = false;
+ if (typing_idle_timer_ != nullptr) {
+ typing_idle_timer_->stop();
+ }
+ emit TypingStateChanged(local_typing_conversation_id_, false);
+ local_typing_conversation_id_.clear();
+ }
QSignalBlocker signal_blocker(conversations_list_);
QSignalBlocker contact_signal_blocker(contacts_panel_list_);
if (conversation_id.isEmpty()) {
conversations_list_->setCurrentItem(nullptr);
contacts_panel_list_->setCurrentItem(nullptr);
+ if (typing_indicator_label_ != nullptr) {
+ typing_indicator_label_->clear();
+ typing_indicator_label_->setVisible(false);
+ }
RefreshConversationSelectionStyles();
UpdateThreadHeader();
UpdateCallControls();
@@ -635,22 +810,31 @@ QWidget* ChatWidget::CreateConversationItemWidget(
row->setProperty("selected", selected);
auto* layout = new QHBoxLayout(row);
- layout->setContentsMargins(10, 8, 10, 8);
- layout->setSpacing(8);
-
- auto* status_dot = new QLabel(row);
- status_dot->setObjectName("presenceDot");
- const QString normalized_status = NormalizePresenceStatus(item.status);
- status_dot->setProperty("state", normalized_status);
- status_dot->setFixedSize(10, 10);
- status_dot->setStyleSheet(
+ layout->setContentsMargins(8, 6, 8, 6);
+ layout->setSpacing(10);
+
+ auto* avatar = new QLabel(row);
+ avatar->setObjectName("conversationAvatar");
+ avatar->setAlignment(Qt::AlignCenter);
+ avatar->setFixedSize(32, 32);
+ QString avatar_initial = item.title.trimmed();
+ if (avatar_initial.isEmpty()) {
+ avatar_initial = "?";
+ }
+ const bool is_group = item.conversation_type.trimmed().toLower() == "group";
+ avatar->setText(is_group ? "G" : avatar_initial.left(1).toUpper());
+ const QString avatar_bg = is_group ? "#3ba55d" : "#5865f2";
+ avatar->setStyleSheet(
QString(
"background:%1;"
- "border-radius:5px;"
- "min-width:10px;max-width:10px;"
- "min-height:10px;max-height:10px;")
- .arg(PresenceColorForStatus(normalized_status)));
- layout->addWidget(status_dot, 0, Qt::AlignTop);
+ "border-radius:16px;"
+ "min-width:32px;max-width:32px;"
+ "min-height:32px;max-height:32px;"
+ "color:#ffffff;"
+ "font-size:13px;"
+ "font-weight:600;")
+ .arg(avatar_bg));
+ layout->addWidget(avatar, 0, Qt::AlignVCenter);
auto* text_col = new QVBoxLayout();
text_col->setSpacing(2);
@@ -694,40 +878,177 @@ QWidget* ChatWidget::CreateConversationItemWidget(
return row;
}
-QWidget* ChatWidget::CreateThreadMessageWidget(const ThreadMessageView& message) const {
+QWidget* ChatWidget::CreateThreadMessageWidget(const ThreadMessageView& message) {
auto* row = new QWidget();
- row->setObjectName("messageRow");
+ row->setObjectName(message.system ? "systemMessageRow" : "messageRow");
row->setProperty("outgoing", message.outgoing);
auto* row_layout = new QHBoxLayout(row);
- row_layout->setContentsMargins(10, message.grouped_with_previous ? 2 : 8, 10, 2);
- row_layout->setSpacing(0);
+ row_layout->setContentsMargins(16, message.grouped_with_previous ? 1 : 12, 48, message.system ? 4 : 1);
+ row_layout->setSpacing(16);
+
+ if (message.system) {
+ QString icon_text = "SYS";
+ const QString lowered = message.body.trimmed().toLower();
+ if (lowered.contains("call")) {
+ icon_text = "CALL";
+ } else if (lowered.contains("group")) {
+ icon_text = "GROUP";
+ }
- auto* bubble = new QWidget(row);
+ auto* icon = new QLabel(icon_text, row);
+ icon->setObjectName("systemMessageIcon");
+ icon->setAlignment(Qt::AlignCenter);
+ icon->setFixedHeight(22);
+ row_layout->addWidget(icon, 0, Qt::AlignTop);
+
+ auto* text = new QLabel(QString("%1 %2").arg(message.body, message.created_at_display), row);
+ text->setObjectName("systemMessageText");
+ text->setWordWrap(true);
+ text->setTextInteractionFlags(Qt::TextSelectableByMouse);
+ row_layout->addWidget(text, 1, Qt::AlignTop);
+ return row;
+ }
+
+ auto* avatar = new QLabel(row);
+ avatar->setObjectName("messageAvatar");
+ avatar->setFixedSize(40, 40);
+ if (message.grouped_with_previous) {
+ avatar->setText({});
+ avatar->setProperty("grouped", true);
+ } else {
+ QString initial = message.sender_label.trimmed();
+ if (initial.isEmpty()) {
+ initial = "U";
+ }
+ avatar->setText(initial.left(1).toUpper());
+ avatar->setProperty("grouped", false);
+ }
+ row_layout->addWidget(avatar, 0, Qt::AlignTop);
+
+ auto* content = new QWidget(row);
+ auto* content_layout = new QVBoxLayout(content);
+ content_layout->setContentsMargins(0, 0, 0, 0);
+ content_layout->setSpacing(2);
+
+ auto* bubble = new QWidget(content);
bubble->setObjectName("messageBubble");
bubble->setProperty("outgoing", message.outgoing);
auto* bubble_layout = new QVBoxLayout(bubble);
- bubble_layout->setContentsMargins(10, 8, 10, 8);
+ bubble_layout->setContentsMargins(0, 0, 0, 0);
bubble_layout->setSpacing(4);
if (!message.grouped_with_previous) {
- auto* meta = new QLabel(QString("%1 %2").arg(message.sender_label, message.created_at_display), bubble);
- meta->setObjectName("messageMeta");
- bubble_layout->addWidget(meta);
+ auto* meta_row = new QHBoxLayout();
+ meta_row->setSpacing(8);
+ auto* sender = new QLabel(message.sender_label, content);
+ sender->setObjectName("messageSender");
+ auto* timestamp = new QLabel(message.created_at_display, content);
+ timestamp->setObjectName("messageTimestamp");
+ meta_row->addWidget(sender);
+ meta_row->addWidget(timestamp);
+ meta_row->addStretch(1);
+ content_layout->addLayout(meta_row);
}
QString file_name;
QByteArray file_bytes;
qint64 file_size = 0;
- const bool is_file = DecodeFileMessageBody(message.body, &file_name, &file_bytes, &file_size);
+ QString mime_type;
+ QString media_kind;
+ const bool is_file = DecodeFileMessageBody(
+ message.body,
+ &file_name,
+ &file_bytes,
+ &file_size,
+ &mime_type,
+ &media_kind);
if (is_file) {
- auto* file_label = new QLabel(
- QString("Encrypted file: %1 (%2)").arg(file_name, HumanFileSize(file_size)),
+ if (media_kind.isEmpty()) {
+ if (mime_type.startsWith("image/")) {
+ media_kind = "image";
+ } else if (mime_type.startsWith("video/")) {
+ media_kind = "video";
+ } else {
+ media_kind = "file";
+ }
+ }
+
+ auto* file_title = new QLabel(
+ QString("%1 (%2)").arg(file_name, HumanFileSize(file_size)),
bubble);
- file_label->setObjectName("messageBody");
- file_label->setWordWrap(true);
- bubble_layout->addWidget(file_label);
+ file_title->setObjectName("messageBody");
+ file_title->setWordWrap(true);
+ bubble_layout->addWidget(file_title);
+
+ if (media_kind == "image") {
+ QPixmap image_preview;
+ image_preview.loadFromData(file_bytes);
+ if (!image_preview.isNull()) {
+ auto* image_label = new QLabel(bubble);
+ image_label->setObjectName("messageImage");
+ image_label->setPixmap(
+ image_preview.scaledToWidth(320, Qt::SmoothTransformation));
+ image_label->setTextInteractionFlags(Qt::TextSelectableByMouse);
+ bubble_layout->addWidget(image_label);
+ }
+ } else if (media_kind == "video") {
+ auto* video_hint = new QLabel("Video attachment. Click play to view.", bubble);
+ video_hint->setObjectName("conversationSubtitle");
+ video_hint->setWordWrap(true);
+ bubble_layout->addWidget(video_hint);
+
+ auto* play_button = new QPushButton("Play video", bubble);
+ play_button->setObjectName("secondaryButton");
+ QObject::connect(play_button, &QPushButton::clicked, bubble, [file_name, file_bytes, this]() {
+ QTemporaryFile temp_file(QDir::tempPath() + "/blackwire_video_XXXXXX");
+ temp_file.setAutoRemove(false);
+ if (!temp_file.open()) {
+ return;
+ }
+ temp_file.write(file_bytes);
+ temp_file.flush();
+ const QString temp_path = temp_file.fileName();
+ temp_file.close();
+
+ QDialog dialog(this);
+ dialog.setWindowTitle(QString("Video: %1").arg(file_name));
+ dialog.resize(800, 520);
+ auto* layout = new QVBoxLayout(&dialog);
+ auto* video = new QVideoWidget(&dialog);
+ video->setMinimumSize(640, 360);
+ layout->addWidget(video, 1);
+
+ auto* controls = new QHBoxLayout();
+ auto* play = new QPushButton("Play", &dialog);
+ auto* pause = new QPushButton("Pause", &dialog);
+ auto* close = new QPushButton("Close", &dialog);
+ play->setObjectName("primaryButton");
+ pause->setObjectName("secondaryButton");
+ close->setObjectName("secondaryButton");
+ controls->addWidget(play);
+ controls->addWidget(pause);
+ controls->addStretch(1);
+ controls->addWidget(close);
+ layout->addLayout(controls);
+
+ auto* audio = new QAudioOutput(&dialog);
+ auto* player = new QMediaPlayer(&dialog);
+ player->setAudioOutput(audio);
+ player->setVideoOutput(video);
+ player->setSource(QUrl::fromLocalFile(temp_path));
+ QObject::connect(play, &QPushButton::clicked, player, &QMediaPlayer::play);
+ QObject::connect(pause, &QPushButton::clicked, player, &QMediaPlayer::pause);
+ QObject::connect(close, &QPushButton::clicked, &dialog, &QDialog::accept);
+ QObject::connect(&dialog, &QDialog::finished, &dialog, [temp_path]() {
+ QFile::remove(temp_path);
+ });
+
+ dialog.exec();
+ });
+ bubble_layout->addWidget(play_button, 0, Qt::AlignLeft);
+ }
auto* save_button = new QPushButton("Save file", bubble);
save_button->setObjectName("secondaryButton");
@@ -745,21 +1066,66 @@ QWidget* ChatWidget::CreateThreadMessageWidget(const ThreadMessageView& message)
});
bubble_layout->addWidget(save_button, 0, Qt::AlignLeft);
} else {
- auto* body = new QLabel(message.body, bubble);
+ auto* body = new QLabel(bubble);
body->setObjectName("messageBody");
body->setWordWrap(true);
- body->setTextInteractionFlags(Qt::TextSelectableByMouse);
+ body->setTextFormat(Qt::MarkdownText);
+ body->setOpenExternalLinks(false);
+ body->setTextInteractionFlags(Qt::TextBrowserInteraction | Qt::TextSelectableByMouse);
+ body->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
+ body->setMaximumWidth(680);
+ body->setText(SanitizeMarkdownSource(message.body));
+ QObject::connect(body, &QLabel::linkActivated, body, [](const QString& url) {
+ const auto answer = QMessageBox::question(
+ nullptr,
+ "Open Link",
+ QString("Do you want to open this link?\n\n%1").arg(url),
+ QMessageBox::Yes | QMessageBox::No,
+ QMessageBox::No);
+ if (answer == QMessageBox::Yes) {
+ QDesktopServices::openUrl(QUrl(url));
+ }
+ });
bubble_layout->addWidget(body);
}
- if (message.outgoing) {
- row_layout->addStretch(1);
- row_layout->addWidget(bubble, 0);
- } else {
- row_layout->addWidget(bubble, 0);
- row_layout->addStretch(1);
+ if (message.outgoing && is_file) {
+ const QString attachment_status = message.attachment_status.trimmed().toLower();
+ if (!attachment_status.isEmpty() && attachment_status != "success") {
+ QString status_text = "Attachment status: ";
+ if (attachment_status == "queued") {
+ status_text += "Queued";
+ } else if (attachment_status == "sending") {
+ status_text += "Sending";
+ } else if (attachment_status == "failed") {
+ status_text += "Failed";
+ } else {
+ status_text += attachment_status;
+ }
+ auto* status_label = new QLabel(status_text, bubble);
+ status_label->setObjectName("conversationSubtitle");
+ bubble_layout->addWidget(status_label);
+ }
+
+ if (message.attachment_retryable) {
+ auto* retry_button = new QPushButton("Retry", bubble);
+ retry_button->setObjectName("secondaryButton");
+ QObject::connect(retry_button, &QPushButton::clicked, bubble, [this, message]() {
+ emit RetryAttachmentRequested(message.id);
+ });
+ bubble_layout->addWidget(retry_button, 0, Qt::AlignLeft);
+ }
+ }
+
+ if (message.outgoing && !message.delivery_badge.trimmed().isEmpty()) {
+ auto* badge = new QLabel(message.delivery_badge, bubble);
+ badge->setObjectName("conversationSubtitle");
+ bubble_layout->addWidget(badge, 0, Qt::AlignRight);
}
+ content_layout->addWidget(bubble, 0, Qt::AlignLeft);
+ row_layout->addWidget(content, 1);
+
return row;
}
@@ -781,29 +1147,36 @@ void ChatWidget::SetConversationList(const std::vector
dm_item->setData(kRolePeerAddress, item.peer_address);
dm_item->setData(kRoleGroupName, item.group_name);
dm_item->setData(kRoleMemberCount, item.member_count);
- dm_item->setSizeHint(QSize(200, 64));
+ dm_item->setSizeHint(QSize(200, 48));
auto* dm_row = CreateConversationItemWidget(item, item.id == selected_id, true);
conversations_list_->setItemWidget(dm_item, dm_row);
- auto* contact_item = new QListWidgetItem(contacts_panel_list_);
- contact_item->setData(kRoleConversationId, item.id);
- contact_item->setData(kRoleTitle, item.title);
- contact_item->setData(kRoleSubtitle, item.subtitle);
- contact_item->setData(kRoleStatus, item.status);
- contact_item->setData(kRoleConversationType, item.conversation_type);
- contact_item->setData(kRoleCanManageMembers, item.can_manage_members);
- contact_item->setData(kRolePeerAddress, item.peer_address);
- contact_item->setData(kRoleGroupName, item.group_name);
- contact_item->setData(kRoleMemberCount, item.member_count);
- contact_item->setSizeHint(QSize(200, 54));
- ConversationListItemView contact_view = item;
- contact_view.subtitle = QString("Status: %1").arg(NormalizePresenceStatus(item.status));
- auto* contact_row = CreateConversationItemWidget(contact_view, item.id == selected_id, false);
- contacts_panel_list_->setItemWidget(contact_item, contact_row);
+ // Only show direct messages in the Friends / contacts panel.
+ const bool is_group = item.conversation_type.trimmed().compare("group", Qt::CaseInsensitive) == 0;
+ QListWidgetItem* contact_item = nullptr;
+ if (!is_group) {
+ contact_item = new QListWidgetItem(contacts_panel_list_);
+ contact_item->setData(kRoleConversationId, item.id);
+ contact_item->setData(kRoleTitle, item.title);
+ contact_item->setData(kRoleSubtitle, item.subtitle);
+ contact_item->setData(kRoleStatus, item.status);
+ contact_item->setData(kRoleConversationType, item.conversation_type);
+ contact_item->setData(kRoleCanManageMembers, item.can_manage_members);
+ contact_item->setData(kRolePeerAddress, item.peer_address);
+ contact_item->setData(kRoleGroupName, item.group_name);
+ contact_item->setData(kRoleMemberCount, item.member_count);
+ contact_item->setSizeHint(QSize(200, 44));
+ ConversationListItemView contact_view = item;
+ contact_view.subtitle = QString("Status: %1").arg(NormalizePresenceStatus(item.status));
+ auto* contact_row = CreateConversationItemWidget(contact_view, item.id == selected_id, false);
+ contacts_panel_list_->setItemWidget(contact_item, contact_row);
+ }
if (!selected_id.isEmpty() && item.id == selected_id) {
conversations_list_->setCurrentItem(dm_item);
- contacts_panel_list_->setCurrentItem(contact_item);
+ if (contact_item != nullptr) {
+ contacts_panel_list_->setCurrentItem(contact_item);
+ }
}
}
@@ -872,6 +1245,10 @@ void ChatWidget::UpdateThreadHeader() {
thread_title_label_->setFocusPolicy(Qt::NoFocus);
thread_title_label_->setCursor(Qt::ArrowCursor);
thread_title_label_->setToolTip({});
+ if (typing_indicator_label_ != nullptr) {
+ typing_indicator_label_->clear();
+ typing_indicator_label_->setVisible(false);
+ }
return;
}
const auto* selected = conversations_list_->currentItem();
@@ -882,6 +1259,10 @@ void ChatWidget::UpdateThreadHeader() {
thread_title_label_->setCursor(Qt::ArrowCursor);
thread_title_label_->setToolTip({});
empty_state_label_->setText("Select a conversation to start chatting.");
+ if (typing_indicator_label_ != nullptr) {
+ typing_indicator_label_->clear();
+ typing_indicator_label_->setVisible(false);
+ }
return;
}
@@ -948,6 +1329,9 @@ void ChatWidget::SetContactsMode(bool enabled) {
UpdateContactsButtonState();
UpdateThreadHeader();
UpdateCallControls();
+ if (typing_indicator_label_ != nullptr) {
+ typing_indicator_label_->setVisible(!contacts_mode_active_ && !typing_indicator_label_->text().trimmed().isEmpty());
+ }
}
void ChatWidget::UpdateContactsButtonState() {
@@ -999,12 +1383,26 @@ void ChatWidget::UpdateCallControls() {
invite_button_->setEnabled(idle_and_chat_ready && selected_owner_managed_group);
accept_call_button_->setVisible(state == "incoming_ringing");
decline_call_button_->setVisible(state == "incoming_ringing");
+ accept_call_button_->setText(selected_group ? "Join" : "Accept");
+ decline_call_button_->setText(selected_group ? "Ignore" : "Decline");
mute_call_button_->setVisible(state == "active");
mute_call_button_->setText(call_state_.muted ? "Unmute" : "Mute");
end_call_button_->setVisible(state == "active" || state == "outgoing_ringing" || state == "ending");
end_call_button_->setEnabled(state != "ending");
- QString peer_title = thread_title_label_->text().trimmed();
+ // Derive peer title from the call target (call_state_) rather than the
+ // currently-selected conversation header so that switching DMs during an
+ // active call does not change the displayed call target.
+ QString peer_title;
+ if (state != "idle") {
+ peer_title = call_state_.peer_user_id.trimmed();
+ if (peer_title.contains('@')) {
+ peer_title = peer_title.section('@', 0, 0).trimmed();
+ }
+ }
+ if (peer_title.isEmpty()) {
+ peer_title = thread_title_label_->text().trimmed();
+ }
if (peer_title.isEmpty()) {
peer_title = "Direct Message";
}
@@ -1214,6 +1612,18 @@ void ChatWidget::SetUserStatus(const QString& status) {
UpdatePresenceIndicator(normalized);
}
+void ChatWidget::SetTypingIndicator(const QString& conversation_id, const QString& text) {
+ if (typing_indicator_label_ == nullptr) {
+ return;
+ }
+ if (!conversation_id.trimmed().isEmpty() && conversation_id != SelectedConversation()) {
+ return;
+ }
+ const QString normalized = text.trimmed();
+ typing_indicator_label_->setText(normalized);
+ typing_indicator_label_->setVisible(!contacts_mode_active_ && !normalized.isEmpty());
+}
+
void ChatWidget::UpdatePresenceIndicator(const QString& status) {
if (presence_indicator_ == nullptr) {
return;
@@ -1259,6 +1669,14 @@ void ChatWidget::ShowBanner(const QString& text, const QString& severity) {
}
void ChatWidget::ClearCompose() {
+ if (local_typing_active_) {
+ local_typing_active_ = false;
+ if (typing_idle_timer_ != nullptr) {
+ typing_idle_timer_->stop();
+ }
+ emit TypingStateChanged(local_typing_conversation_id_, false);
+ local_typing_conversation_id_.clear();
+ }
compose_input_->clear();
SetSendEnabled(false);
}
diff --git a/client-cpp-gui/src/ui/login_widget.cpp b/client-cpp-gui/src/ui/login_widget.cpp
index 8ef3d50..d5de491 100644
--- a/client-cpp-gui/src/ui/login_widget.cpp
+++ b/client-cpp-gui/src/ui/login_widget.cpp
@@ -1,6 +1,5 @@
#include "blackwire/ui/login_widget.hpp"
-#include
#include
#include
#include
@@ -10,50 +9,85 @@
namespace blackwire {
LoginWidget::LoginWidget(QWidget* parent) : QWidget(parent) {
- auto* layout = new QVBoxLayout(this);
- layout->setContentsMargins(24, 24, 24, 24);
- layout->setSpacing(14);
+ setStyleSheet("background: #313338;");
- auto* title = new QLabel("Welcome back", this);
- title->setObjectName("threadTitle");
+ auto* outer = new QVBoxLayout(this);
+ outer->setContentsMargins(0, 0, 0, 0);
+ outer->setAlignment(Qt::AlignCenter);
+
+ auto* card = new QWidget(this);
+ card->setObjectName("loginCard");
+ card->setFixedWidth(480);
+ card->setMinimumHeight(400);
+
+ auto* layout = new QVBoxLayout(card);
+ layout->setContentsMargins(32, 32, 32, 32);
+ layout->setSpacing(16);
+
+ auto* title = new QLabel("Welcome back!", card);
+ title->setObjectName("loginTitle");
+ title->setAlignment(Qt::AlignCenter);
layout->addWidget(title);
auto* subtitle = new QLabel(
- "Connect to your home server URL (IP/domain; .onion optional). "
- "Onion login may require a local Tor SOCKS proxy.",
- this);
- subtitle->setObjectName("conversationSubtitle");
+ "We're so excited to see you again!",
+ card);
+ subtitle->setObjectName("loginSubtitle");
+ subtitle->setAlignment(Qt::AlignCenter);
+ subtitle->setWordWrap(true);
layout->addWidget(subtitle);
+ layout->addSpacing(8);
- auto* form = new QFormLayout();
- form->setSpacing(10);
- base_url_input_ = new QLineEdit("http://localhost:8000", this);
+ auto* server_label = new QLabel("HOME SERVER URL", card);
+ server_label->setObjectName("loginFieldLabel");
+ layout->addWidget(server_label);
+ base_url_input_ = new QLineEdit("http://localhost:8000", card);
base_url_input_->setPlaceholderText("http://localhost:8000 or http://yourserver.onion");
- username_input_ = new QLineEdit(this);
- password_input_ = new QLineEdit(this);
+ layout->addWidget(base_url_input_);
+
+ auto* user_label = new QLabel("USERNAME", card);
+ user_label->setObjectName("loginFieldLabel");
+ layout->addWidget(user_label);
+ username_input_ = new QLineEdit(card);
+ layout->addWidget(username_input_);
+
+ auto* pass_label = new QLabel("PASSWORD", card);
+ pass_label->setObjectName("loginFieldLabel");
+ layout->addWidget(pass_label);
+ password_input_ = new QLineEdit(card);
password_input_->setEchoMode(QLineEdit::Password);
+ layout->addWidget(password_input_);
- form->addRow("Home Server URL (IP/domain; onion optional)", base_url_input_);
- form->addRow("Username", username_input_);
- form->addRow("Password", password_input_);
-
- layout->addLayout(form);
+ layout->addSpacing(4);
- auto* actions = new QHBoxLayout();
- actions->setSpacing(10);
-
- login_button_ = new QPushButton("Login", this);
+ login_button_ = new QPushButton("Log In", card);
login_button_->setObjectName("primaryButton");
+ login_button_->setFixedHeight(44);
+ login_button_->setStyleSheet(
+ "QPushButton { background: #5865f2; border: none; border-radius: 3px;"
+ " color: #ffffff; font-size: 16px; font-weight: 500; }"
+ "QPushButton:hover { background: #4752c4; }");
+ layout->addWidget(login_button_);
+
+ auto* register_row = new QHBoxLayout();
+ register_row->setSpacing(4);
+ auto* need_label = new QLabel("Need an account?", card);
+ need_label->setObjectName("loginSubtitle");
+ register_button_ = new QPushButton("Register", card);
+ register_button_->setStyleSheet(
+ "QPushButton { background: transparent; border: none; color: #00a8fc;"
+ " font-size: 14px; font-weight: 500; padding: 0; }"
+ "QPushButton:hover { text-decoration: underline; }");
+ register_row->addStretch(1);
+ register_row->addWidget(need_label);
+ register_row->addWidget(register_button_);
+ register_row->addStretch(1);
+ layout->addLayout(register_row);
- register_button_ = new QPushButton("Register", this);
- register_button_->setObjectName("secondaryButton");
-
- actions->addWidget(login_button_);
- actions->addWidget(register_button_);
- actions->addStretch(1);
- layout->addLayout(actions);
layout->addStretch(1);
+ outer->addWidget(card);
+
connect(login_button_, &QPushButton::clicked, this, &LoginWidget::LoginRequested);
connect(register_button_, &QPushButton::clicked, this, &LoginWidget::RegisterRequested);
}
diff --git a/client-cpp-gui/src/ui/main_window.cpp b/client-cpp-gui/src/ui/main_window.cpp
index 33abc1b..f1d0ac8 100644
--- a/client-cpp-gui/src/ui/main_window.cpp
+++ b/client-cpp-gui/src/ui/main_window.cpp
@@ -132,6 +132,14 @@ MainWindow::MainWindow(ApplicationController& controller, QWidget* parent)
controller_.SendFileToPeer(QString(), file_path);
});
+ connect(chat_widget_, &ChatWidget::RetryAttachmentRequested, this, [this](const QString& message_id) {
+ controller_.RetryFailedAttachment(message_id);
+ });
+
+ connect(chat_widget_, &ChatWidget::TypingStateChanged, this, [this](const QString& conversation_id, bool typing) {
+ controller_.PublishTypingState(conversation_id, typing);
+ });
+
connect(chat_widget_, &ChatWidget::UserStatusChanged, this, [this](const QString& status) {
controller_.SetPresenceStatus(status);
});
@@ -159,13 +167,16 @@ MainWindow::MainWindow(ApplicationController& controller, QWidget* parent)
connect(chat_widget_, &ChatWidget::SettingsRequested, this, [this]() {
controller_.LoadAudioDevices();
controller_.LoadAccountDevices();
+ controller_.LoadSystemVersion();
settings_dialog_->SetIdentity(controller_.UserDisplayId());
settings_dialog_->SetServerUrl(controller_.BaseUrl());
settings_dialog_->SetDeviceInfo(controller_.DeviceLabel(), controller_.DeviceId());
+ settings_dialog_->SetVersionInfo(controller_.ClientVersion(), controller_.ServerVersion());
settings_dialog_->SetConnectionStatus(controller_.ConnectionStatus());
settings_dialog_->SetDiagnostics(controller_.DiagnosticsReport());
settings_dialog_->SetIntegrityWarning(last_integrity_warning_);
settings_dialog_->SetAcceptMessagesFromStrangers(controller_.AcceptMessagesFromStrangers());
+ settings_dialog_->SetSaveMessageCache(controller_.SaveMessageCache());
settings_dialog_->exec();
});
@@ -181,6 +192,10 @@ MainWindow::MainWindow(ApplicationController& controller, QWidget* parent)
controller_.SetAcceptMessagesFromStrangers(enabled);
});
+ connect(settings_dialog_, &SettingsDialog::SaveMessageCacheChanged, this, [this](bool enabled) {
+ controller_.SetSaveMessageCache(enabled);
+ });
+
connect(settings_dialog_, &SettingsDialog::RevokeDeviceRequested, this, [this](const QString& device_uid) {
const auto answer = QMessageBox::question(
this,
@@ -212,6 +227,7 @@ MainWindow::MainWindow(ApplicationController& controller, QWidget* parent)
last_integrity_warning_.clear();
settings_dialog_->SetIntegrityWarning(last_integrity_warning_);
settings_dialog_->SetAccountDevices(std::vector{}, QString());
+ settings_dialog_->SetVersionInfo(controller_.ClientVersion(), QString());
login_widget_->SetBaseUrl(controller_.BaseUrl());
stack->setCurrentWidget(login_widget_);
return;
@@ -240,6 +256,9 @@ MainWindow::MainWindow(ApplicationController& controller, QWidget* parent)
[this](const QString& conversation_id, const std::vector& messages) {
chat_widget_->SetSelectedConversation(conversation_id);
chat_widget_->SetThreadMessages(messages);
+ if (conversation_id.trimmed().isEmpty()) {
+ chat_widget_->SetTypingIndicator(QString(), QString());
+ }
});
connect(
@@ -253,6 +272,17 @@ MainWindow::MainWindow(ApplicationController& controller, QWidget* parent)
chat_widget_->AppendThreadMessage(message);
});
+ connect(
+ &controller_,
+ &ApplicationController::TypingIndicatorChanged,
+ this,
+ [this](const QString& conversation_id, const QString& text) {
+ if (conversation_id != chat_widget_->SelectedConversation()) {
+ return;
+ }
+ chat_widget_->SetTypingIndicator(conversation_id, text);
+ });
+
connect(
&controller_,
&ApplicationController::MessageRequestReceived,
diff --git a/client-cpp-gui/src/ui/settings_dialog.cpp b/client-cpp-gui/src/ui/settings_dialog.cpp
index 7b9dd77..cfffd4d 100644
--- a/client-cpp-gui/src/ui/settings_dialog.cpp
+++ b/client-cpp-gui/src/ui/settings_dialog.cpp
@@ -142,6 +142,10 @@ SettingsDialog::SettingsDialog(QWidget* parent) : QDialog(parent) {
emit AcceptMessagesFromStrangersChanged(enabled);
});
+ QObject::connect(save_message_cache_checkbox_, &QCheckBox::toggled, this, [this](bool enabled) {
+ emit SaveMessageCacheChanged(enabled);
+ });
+
QObject::connect(this, &QDialog::finished, this, [this](int) { StopAudioMonitor(); });
}
@@ -222,18 +226,24 @@ void SettingsDialog::BuildMyAccountPage() {
server_url_value_ = new QLabel("-", page);
device_label_value_ = new QLabel("-", page);
device_id_value_ = new QLabel("-", page);
+ client_version_value_ = new QLabel("-", page);
+ server_version_value_ = new QLabel("-", page);
connection_status_value_ = new QLabel("-", page);
identity_value_->setTextInteractionFlags(Qt::TextSelectableByMouse);
server_url_value_->setTextInteractionFlags(Qt::TextSelectableByMouse);
device_label_value_->setTextInteractionFlags(Qt::TextSelectableByMouse);
device_id_value_->setTextInteractionFlags(Qt::TextSelectableByMouse);
+ client_version_value_->setTextInteractionFlags(Qt::TextSelectableByMouse);
+ server_version_value_->setTextInteractionFlags(Qt::TextSelectableByMouse);
connection_status_value_->setTextInteractionFlags(Qt::TextSelectableByMouse);
form->addRow("Your ID", identity_value_);
form->addRow("Home Server URL", server_url_value_);
form->addRow("Device Label", device_label_value_);
form->addRow("Device ID", device_id_value_);
+ form->addRow("Client Version", client_version_value_);
+ form->addRow("Server Version", server_version_value_);
form->addRow("Connection", connection_status_value_);
layout->addLayout(form);
@@ -350,6 +360,27 @@ void SettingsDialog::BuildPrivacyPage() {
title->setObjectName("threadTitle");
layout->addWidget(title);
+ auto* cache_section_title = new QLabel("Message Storage", page);
+ cache_section_title->setObjectName("conversationSubtitle");
+ layout->addWidget(cache_section_title);
+
+ auto* cache_text =
+ new QLabel("When enabled, messages are saved to an encrypted local cache so they persist across restarts. "
+ "When disabled, message history is cleared when the client exits.",
+ page);
+ cache_text->setObjectName("conversationSubtitle");
+ cache_text->setWordWrap(true);
+ layout->addWidget(cache_text);
+
+ save_message_cache_checkbox_ = new QCheckBox("Save messages to encrypted cache", page);
+ save_message_cache_checkbox_->setChecked(false);
+ layout->addWidget(save_message_cache_checkbox_, 0, Qt::AlignLeft);
+
+ auto* separator = new QWidget(page);
+ separator->setFixedHeight(1);
+ separator->setStyleSheet("background-color: #3f4147;");
+ layout->addWidget(separator);
+
auto* text =
new QLabel("Diagnostics includes local state, connection status, and recent events for troubleshooting.", page);
text->setObjectName("conversationSubtitle");
@@ -441,6 +472,15 @@ void SettingsDialog::SetDeviceInfo(const QString& label, const QString& device_i
copy_device_button_->setEnabled(!device_id_.isEmpty());
}
+void SettingsDialog::SetVersionInfo(const QString& client_version, const QString& server_version) {
+ if (client_version_value_ != nullptr) {
+ client_version_value_->setText(client_version.trimmed().isEmpty() ? "-" : client_version.trimmed());
+ }
+ if (server_version_value_ != nullptr) {
+ server_version_value_->setText(server_version.trimmed().isEmpty() ? "-" : server_version.trimmed());
+ }
+}
+
void SettingsDialog::SetConnectionStatus(const QString& status) {
connection_status_value_->setText(status.isEmpty() ? "-" : status);
}
@@ -553,6 +593,21 @@ bool SettingsDialog::AcceptMessagesFromStrangers() const {
return accept_messages_checkbox_->isChecked();
}
+void SettingsDialog::SetSaveMessageCache(bool enabled) {
+ if (save_message_cache_checkbox_ == nullptr) {
+ return;
+ }
+ const QSignalBlocker blocker(save_message_cache_checkbox_);
+ save_message_cache_checkbox_->setChecked(enabled);
+}
+
+bool SettingsDialog::SaveMessageCache() const {
+ if (save_message_cache_checkbox_ == nullptr) {
+ return false;
+ }
+ return save_message_cache_checkbox_->isChecked();
+}
+
void SettingsDialog::RefreshAudioMonitor() {
if (mic_monitor_checkbox_ == nullptr || !mic_monitor_checkbox_->isChecked()) {
return;
diff --git a/client-cpp-gui/src/ui/theme.cpp b/client-cpp-gui/src/ui/theme.cpp
index 4cad099..b4efb44 100644
--- a/client-cpp-gui/src/ui/theme.cpp
+++ b/client-cpp-gui/src/ui/theme.cpp
@@ -2,343 +2,450 @@
#include
#include
+#include
#include
namespace blackwire {
void ApplyAppTheme(QApplication& app) {
+ QFont default_font("Segoe UI", 10);
+ default_font.setStyleStrategy(QFont::PreferAntialias);
+ app.setFont(default_font);
+
QPalette palette;
- palette.setColor(QPalette::Window, QColor("#1e1f22"));
- palette.setColor(QPalette::WindowText, QColor("#f2f3f5"));
- palette.setColor(QPalette::Base, QColor("#2b2d31"));
- palette.setColor(QPalette::AlternateBase, QColor("#313338"));
- palette.setColor(QPalette::ToolTipBase, QColor("#313338"));
- palette.setColor(QPalette::ToolTipText, QColor("#f2f3f5"));
- palette.setColor(QPalette::Text, QColor("#f2f3f5"));
- palette.setColor(QPalette::Button, QColor("#313338"));
- palette.setColor(QPalette::ButtonText, QColor("#f2f3f5"));
+ palette.setColor(QPalette::Window, QColor("#313338"));
+ palette.setColor(QPalette::WindowText, QColor("#dbdee1"));
+ palette.setColor(QPalette::Base, QColor("#1e1f22"));
+ palette.setColor(QPalette::AlternateBase, QColor("#2b2d31"));
+ palette.setColor(QPalette::ToolTipBase, QColor("#111214"));
+ palette.setColor(QPalette::ToolTipText, QColor("#dbdee1"));
+ palette.setColor(QPalette::Text, QColor("#dbdee1"));
+ palette.setColor(QPalette::Button, QColor("#2b2d31"));
+ palette.setColor(QPalette::ButtonText, QColor("#dbdee1"));
palette.setColor(QPalette::BrightText, QColor("#ffffff"));
- palette.setColor(QPalette::Link, QColor("#5865f2"));
+ palette.setColor(QPalette::Link, QColor("#00a8fc"));
palette.setColor(QPalette::Highlight, QColor("#5865f2"));
palette.setColor(QPalette::HighlightedText, QColor("#ffffff"));
app.setPalette(palette);
const QString qss = R"(
+
+/* ── Global ───────────────────────────────────────────────── */
QWidget {
- background: #2b2d31;
- color: #f2f3f5;
- font-size: 13px;
+ background: #313338;
+ color: #dbdee1;
+ font-family: "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif;
+ font-size: 14px;
}
QMainWindow, QDialog {
- background: #1e1f22;
+ background: #313338;
}
QStatusBar {
- background: #1e1f22;
- color: #b5bac1;
- border-top: 1px solid #24262a;
+ background: #232428;
+ color: #949ba4;
+ border-top: 1px solid #1e1f22;
+ font-size: 12px;
+ padding: 2px 8px;
+}
+
+/* ── Scrollbars (Discord-thin) ────────────────────────────── */
+QScrollBar:vertical {
+ background: transparent;
+ width: 8px;
+ margin: 0;
+}
+QScrollBar::handle:vertical {
+ background: #1a1b1e;
+ border-radius: 4px;
+ min-height: 40px;
+}
+QScrollBar::handle:vertical:hover {
+ background: #27282c;
+}
+QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical,
+QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {
+ background: transparent;
+ height: 0;
+ border: none;
+}
+QScrollBar:horizontal {
+ background: transparent;
+ height: 8px;
+}
+QScrollBar::handle:horizontal {
+ background: #1a1b1e;
+ border-radius: 4px;
+ min-width: 40px;
+}
+QScrollBar::handle:horizontal:hover {
+ background: #27282c;
+}
+QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal,
+QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal {
+ background: transparent;
+ width: 0;
+ border: none;
}
+/* ── Inputs ───────────────────────────────────────────────── */
QLineEdit, QPlainTextEdit, QListWidget, QComboBox {
background: #1e1f22;
- border: 1px solid #1b1c20;
+ border: none;
border-radius: 8px;
- color: #f2f3f5;
+ color: #dbdee1;
selection-background-color: #5865f2;
- padding: 6px 8px;
+ padding: 8px 12px;
+ font-size: 14px;
}
QComboBox#statusCombo {
- min-width: 96px;
+ min-width: 100px;
+ background: #1e1f22;
+ border-radius: 4px;
+ padding: 4px 8px;
}
QLineEdit:focus, QPlainTextEdit:focus, QComboBox:focus {
- border-color: #5865f2;
+ outline: none;
}
QComboBox::drop-down {
border: none;
width: 20px;
}
+QComboBox QAbstractItemView {
+ background: #2b2d31;
+ border: 1px solid #1e1f22;
+ border-radius: 4px;
+ selection-background-color: #404249;
+ color: #dbdee1;
+}
-QPushButton {
+/* ── Compose input (Discord chat bar) ─────────────────────── */
+QPlainTextEdit#composeInput {
+ background: #383a40;
+ border: none;
border-radius: 8px;
- padding: 6px 12px;
- background: #3f4248;
- color: #f2f3f5;
- border: 1px solid #24262a;
+ color: #dbdee1;
+ padding: 10px 16px;
+ font-size: 14px;
+}
+
+/* ── Buttons ──────────────────────────────────────────────── */
+QPushButton {
+ border-radius: 3px;
+ padding: 7px 16px;
+ background: #4e5058;
+ color: #ffffff;
+ border: none;
+ font-size: 14px;
+ font-weight: 500;
}
QPushButton:hover {
- background: #4a4d54;
+ background: #6d6f78;
}
QPushButton:pressed {
- background: #35383e;
+ background: #43444b;
}
QPushButton:disabled {
- color: #7c8088;
- background: #2c2f34;
+ color: #4e5058;
+ background: #2b2d31;
}
QPushButton#primaryButton {
background: #5865f2;
- border: 1px solid #4a57d8;
- font-weight: 600;
+ border: none;
+ font-weight: 500;
}
QPushButton#primaryButton:hover {
- background: #4e5ada;
+ background: #4752c4;
}
QPushButton#primaryButton:pressed {
- background: #434dc0;
+ background: #3c45a5;
}
QPushButton#secondaryButton {
- background: #3a3d44;
+ background: #4e5058;
+}
+
+QPushButton#secondaryButton:hover {
+ background: #6d6f78;
}
QPushButton#secondaryButton:checked {
- background: #4e5ada;
- border: 1px solid #4a57d8;
+ background: #5865f2;
color: #ffffff;
}
QPushButton#dangerButton {
background: #da373c;
- border: 1px solid #b32f33;
+ border: none;
color: #ffffff;
- font-weight: 600;
+ font-weight: 500;
}
QPushButton#dangerButton:hover {
- background: #c63237;
+ background: #a12d31;
}
+/* ── DM Sidebar (Discord channels pane) ───────────────────── */
QWidget#dmSidebar {
background: #2b2d31;
- border-right: 1px solid #1b1c20;
+ border-right: none;
}
QWidget#settingsSidebar {
- background: #232428;
- border-right: 1px solid #1b1c20;
+ background: #2b2d31;
+ border-right: none;
}
QListWidget#settingsTabList {
background: transparent;
border: none;
- padding: 2px;
+ padding: 4px 8px;
}
QListWidget#settingsTabList::item {
- color: #b5bac1;
- border-radius: 8px;
- padding: 9px 10px;
+ color: #949ba4;
+ border-radius: 4px;
+ padding: 8px 10px;
margin: 1px 0px;
+ font-size: 14px;
}
QListWidget#settingsTabList::item:hover {
- background: #2e3035;
- color: #eef0f3;
+ background: #35373c;
+ color: #dbdee1;
}
QListWidget#settingsTabList::item:selected {
- background: #3a3d45;
+ background: #404249;
color: #ffffff;
font-weight: 600;
}
QLabel#dmSidebarTitle {
- font-size: 14px;
- font-weight: 700;
- color: #ffffff;
+ font-size: 12px;
+ font-weight: 600;
+ color: #949ba4;
+ text-transform: uppercase;
+ padding: 0 8px;
}
QLabel#contactsSidebarTitle {
- font-size: 13px;
- font-weight: 700;
- color: #d6d9de;
+ font-size: 12px;
+ font-weight: 600;
+ color: #949ba4;
+ text-transform: uppercase;
+ padding: 0 8px;
}
+/* ── Identity card (Discord user panel) ───────────────────── */
QWidget#identityCard {
background: #232428;
- border: 1px solid #1b1c20;
- border-radius: 10px;
+ border: none;
+ border-radius: 0;
+ border-top: 1px solid #1e1f22;
}
QWidget#contactsToggleCard {
- background: #232428;
- border: 1px solid #1b1c20;
- border-radius: 10px;
+ background: transparent;
+ border: none;
+ border-radius: 4px;
}
QLabel#identityLabel {
- color: #d6d9de;
+ color: #949ba4;
+ font-size: 12px;
}
+/* ── Chat pane ────────────────────────────────────────────── */
QWidget#chatPane {
background: #313338;
}
+/* ── Header bar ───────────────────────────────────────────── */
+QWidget#chatHeaderBar {
+ background: #313338;
+ border-bottom: 1px solid #232428;
+}
+
QLabel#threadTitle, QLineEdit#threadTitle {
- font-size: 15px;
- font-weight: 700;
+ font-size: 16px;
+ font-weight: 600;
color: #ffffff;
background: transparent;
- border: 1px solid transparent;
- border-radius: 6px;
- padding: 2px 6px;
+ border: none;
+ border-radius: 4px;
+ padding: 2px 4px;
}
QLineEdit#threadTitle:focus {
- border-color: #5865f2;
- background: #232428;
+ border: 1px solid #5865f2;
+ background: #1e1f22;
}
+/* ── Connection / status pills ────────────────────────────── */
QLabel#connectionPill {
- border-radius: 10px;
+ border-radius: 3px;
padding: 2px 8px;
- background: #3f4248;
- color: #d6d9de;
- border: 1px solid #2b2d31;
+ background: #4e5058;
+ color: #dbdee1;
+ border: none;
+ font-size: 12px;
+ font-weight: 500;
}
-QLabel#connectionPill[state=\"connected\"] {
- background: #2d7d46;
- border-color: #236438;
+QLabel#connectionPill[state="connected"] {
+ background: #248046;
}
-QLabel#connectionPill[state=\"warning\"] {
- background: #9a6a1b;
- border-color: #7e5615;
+QLabel#connectionPill[state="warning"] {
+ background: #f0b232;
+ color: #1e1f22;
}
-QLabel#connectionPill[state=\"error\"] {
- background: #9e2c31;
- border-color: #7f2428;
+QLabel#connectionPill[state="error"] {
+ background: #da373c;
}
+/* ── Call status pill ─────────────────────────────────────── */
QLabel#callStatusPill {
- border-radius: 10px;
+ border-radius: 3px;
padding: 2px 8px;
- background: #3f4248;
- color: #d6d9de;
- border: 1px solid #2b2d31;
+ background: #4e5058;
+ color: #dbdee1;
+ border: none;
+ font-size: 12px;
+ font-weight: 500;
}
-QLabel#callStatusPill[state=\"ringing\"] {
- background: #9a6a1b;
- border-color: #7e5615;
+QLabel#callStatusPill[state="ringing"] {
+ background: #f0b232;
+ color: #1e1f22;
}
-QLabel#callStatusPill[state=\"active\"] {
- background: #2d7d46;
- border-color: #236438;
+QLabel#callStatusPill[state="active"] {
+ background: #248046;
}
-QLabel#callStatusPill[state=\"warning\"] {
- background: #9a6a1b;
- border-color: #7e5615;
+QLabel#callStatusPill[state="warning"] {
+ background: #f0b232;
+ color: #1e1f22;
}
-QLabel#callStatusPill[state=\"error\"] {
- background: #9e2c31;
- border-color: #7f2428;
+QLabel#callStatusPill[state="error"] {
+ background: #da373c;
}
+/* ── Call panel ────────────────────────────────────────────── */
QWidget#callPanel {
- background: #23262c;
- border: 1px solid #1b1c20;
- border-radius: 12px;
+ background: #2b2d31;
+ border: 1px solid #1e1f22;
+ border-radius: 8px;
}
-QWidget#callPanel[state=\"ringing\"] {
- border-color: #9a6a1b;
- background: #2d2921;
+QWidget#callPanel[state="ringing"] {
+ border-color: #f0b232;
+ background: #2e2b21;
}
-QWidget#callPanel[state=\"active\"] {
- border-color: #236438;
+QWidget#callPanel[state="active"] {
+ border-color: #248046;
background: #1f2d24;
}
-QWidget#callPanel[state=\"warning\"] {
- border-color: #9a6a1b;
+QWidget#callPanel[state="warning"] {
+ border-color: #f0b232;
}
-QWidget#callPanel[state=\"error\"] {
- border-color: #7f2428;
+QWidget#callPanel[state="error"] {
+ border-color: #da373c;
}
QLabel#callPanelAvatar {
background: #5865f2;
border-radius: 28px;
color: #ffffff;
- font-size: 22px;
- font-weight: 700;
+ font-size: 20px;
+ font-weight: 600;
}
QLabel#callPanelTitle {
color: #ffffff;
font-size: 16px;
- font-weight: 700;
+ font-weight: 600;
}
QLabel#callPanelSubtitle {
- color: #c6cbd2;
+ color: #949ba4;
+ font-size: 13px;
}
QLabel#callParticipantChip {
- background: #31353d;
- border: 1px solid #1b1c20;
- border-radius: 10px;
- padding: 3px 8px;
- color: #d6d9de;
+ background: #2b2d31;
+ border: 1px solid #1e1f22;
+ border-radius: 12px;
+ padding: 4px 10px;
+ color: #dbdee1;
font-size: 12px;
}
-QLabel#callParticipantChip[self=\"true\"] {
- background: #4250c7;
- border-color: #4a57d8;
+QLabel#callParticipantChip[self="true"] {
+ background: #5865f2;
+ border-color: #4752c4;
color: #ffffff;
font-weight: 600;
}
+/* ── Chat banner ──────────────────────────────────────────── */
QLabel#chatBanner {
- border-radius: 8px;
- padding: 8px 10px;
+ border-radius: 4px;
+ padding: 8px 12px;
color: #ffffff;
- background: #3f4248;
+ background: #4e5058;
+ font-size: 14px;
}
-QLabel#chatBanner[severity=\"info\"] {
- background: #3756d9;
+QLabel#chatBanner[severity="info"] {
+ background: #5865f2;
}
-QLabel#chatBanner[severity=\"warning\"] {
- background: #9a6a1b;
+QLabel#chatBanner[severity="warning"] {
+ background: #f0b232;
+ color: #1e1f22;
}
-QLabel#chatBanner[severity=\"error\"] {
+QLabel#chatBanner[severity="error"] {
background: #da373c;
}
+/* ── Conversation list (Discord DM list) ──────────────────── */
QListWidget#conversationList {
background: #2b2d31;
- border: 1px solid #1b1c20;
+ border: none;
}
QListWidget#contactsList {
background: #2b2d31;
- border: 1px solid #1b1c20;
+ border: none;
}
QListWidget#conversationList::item {
- border-radius: 8px;
+ border-radius: 4px;
+ padding: 0;
+ margin: 1px 8px;
+}
+
+QListWidget#conversationList::item:hover {
+ background: #35373c;
}
QListWidget#conversationList::item:selected {
@@ -346,80 +453,103 @@ QListWidget#conversationList::item:selected {
}
QListWidget#contactsList::item {
- border-radius: 8px;
+ border-radius: 4px;
+ padding: 0;
+ margin: 1px 8px;
+}
+
+QListWidget#contactsList::item:hover {
+ background: #35373c;
}
QListWidget#contactsList::item:selected {
background: #404249;
}
+/* ── Conversation row widget ──────────────────────────────── */
QWidget#conversationRow {
background: transparent;
- border-radius: 8px;
+ border-radius: 4px;
}
-QWidget#conversationRow[selected=\"true\"] {
+QWidget#conversationRow[selected="true"] {
background: #404249;
}
+QLabel#conversationAvatar {
+ min-width: 32px;
+ max-width: 32px;
+ min-height: 32px;
+ max-height: 32px;
+ border-radius: 16px;
+ background: #5865f2;
+ color: #ffffff;
+ font-size: 13px;
+ font-weight: 600;
+}
+
QLabel#conversationTitle {
color: #f2f3f5;
- font-weight: 600;
+ font-weight: 500;
+ font-size: 14px;
}
QLabel#conversationSubtitle {
- color: #b5bac1;
+ color: #949ba4;
+ font-size: 12px;
}
QLabel#conversationTime {
- color: #9aa0aa;
+ color: #949ba4;
font-size: 11px;
}
QPushButton#conversationRemoveButton {
- min-width: 18px;
- max-width: 18px;
- min-height: 18px;
- max-height: 18px;
- border-radius: 9px;
- border: 1px solid #2e3138;
- background: #2c2f35;
- color: #cfd3da;
+ min-width: 16px;
+ max-width: 16px;
+ min-height: 16px;
+ max-height: 16px;
+ border-radius: 3px;
+ border: none;
+ background: transparent;
+ color: #949ba4;
padding: 0px;
+ font-size: 12px;
font-weight: 600;
}
QPushButton#conversationRemoveButton:hover {
- background: #c63237;
- border-color: #b32f33;
+ background: #da373c;
color: #ffffff;
}
+/* ── Presence dot ─────────────────────────────────────────── */
QLabel#presenceDot {
border-radius: 5px;
min-width: 10px;
max-width: 10px;
min-height: 10px;
max-height: 10px;
- background: #6a6f78;
+ background: #80848e;
}
-QLabel#presenceDot[state=\"active\"] {
- background: #2d7d46;
+QLabel#presenceDot[state="active"] {
+ background: #23a55a;
}
-QLabel#presenceDot[state=\"inactive\"] {
- background: #d4a63c;
+QLabel#presenceDot[state="inactive"] {
+ background: #f0b232;
}
-QLabel#presenceDot[state=\"offline\"] {
- background: #6a6f78;
+QLabel#presenceDot[state="offline"] {
+ background: #80848e;
}
-QLabel#presenceDot[state=\"dnd\"] {
- background: #9e2c31;
+QLabel#presenceDot[state="dnd"] {
+ background: #f23f43;
}
+/* ── Message list (Discord chat area) ─────────────────────── */
QListWidget#messageList {
background: #313338;
border: none;
@@ -432,49 +562,144 @@ QListWidget#messageList::item {
padding: 0px;
}
+QListWidget#messageList::item:hover {
+ background: #2e3035;
+}
+
+/* ── Message row (Discord flat messages) ──────────────────── */
QWidget#messageRow {
background: transparent;
}
+QLabel#messageAvatar {
+ min-width: 40px;
+ max-width: 40px;
+ min-height: 40px;
+ max-height: 40px;
+ border-radius: 20px;
+ background: #5865f2;
+ border: none;
+ color: #ffffff;
+ font-size: 15px;
+ font-weight: 600;
+}
+
+QLabel#messageAvatar[grouped="true"] {
+ background: transparent;
+ border-color: transparent;
+ color: transparent;
+}
+
+/* No bubble -- flat Discord-style messages */
QWidget#messageBubble {
- border-radius: 10px;
- background: #2b2d31;
- border: 1px solid #232428;
+ border-radius: 0;
+ background: transparent;
+ border: none;
}
-QWidget#messageBubble[outgoing=\"true\"] {
- background: #5865f2;
- border-color: #4f5bda;
+QLabel#messageSender {
+ color: #f2f3f5;
+ font-size: 14px;
+ font-weight: 500;
+}
+
+QLabel#messageTimestamp {
+ color: #949ba4;
+ font-size: 12px;
}
QLabel#messageMeta {
- color: #b8bec7;
- font-size: 11px;
- font-weight: 600;
+ color: #f2f3f5;
+ font-size: 14px;
+ font-weight: 500;
}
QLabel#messageBody {
- color: #f2f3f5;
+ color: #dbdee1;
+ font-size: 14px;
+}
+
+/* ── System messages ──────────────────────────────────────── */
+QWidget#systemMessageRow {
+ background: transparent;
}
+QLabel#systemMessageIcon {
+ border-radius: 3px;
+ padding: 2px 6px;
+ background: #248046;
+ border: none;
+ color: #ffffff;
+ font-size: 10px;
+ font-weight: 700;
+}
+
+QLabel#systemMessageText {
+ color: #949ba4;
+ font-size: 13px;
+}
+
+/* ── Empty state ──────────────────────────────────────────── */
QLabel#chatEmptyState {
- color: #b5bac1;
- font-size: 14px;
+ color: #949ba4;
+ font-size: 16px;
}
+/* ── Audio meter ──────────────────────────────────────────── */
QProgressBar#audioLevelMeter {
- border: 1px solid #1b1c20;
- border-radius: 6px;
+ border: none;
+ border-radius: 4px;
background: #1e1f22;
}
QProgressBar#audioLevelMeter::chunk {
- border-radius: 6px;
- background: #3ba55d;
+ border-radius: 4px;
+ background: #23a55a;
}
+/* ── Dialog labels ────────────────────────────────────────── */
QDialog QLabel {
- color: #d6d9de;
+ color: #dbdee1;
+}
+
+/* ── Splitter handle ──────────────────────────────────────── */
+QSplitter::handle {
+ background: #1e1f22;
+}
+
+/* ── Tooltips ─────────────────────────────────────────────── */
+QToolTip {
+ background: #111214;
+ color: #dbdee1;
+ border: 1px solid #1e1f22;
+ border-radius: 4px;
+ padding: 6px 10px;
+ font-size: 13px;
+}
+
+/* ── Login card ───────────────────────────────────────────── */
+QWidget#loginCard {
+ background: #2b2d31;
+ border-radius: 6px;
+ border: none;
+}
+
+QLabel#loginTitle {
+ color: #f2f3f5;
+ font-size: 24px;
+ font-weight: 600;
+}
+
+QLabel#loginSubtitle {
+ color: #949ba4;
+ font-size: 14px;
+}
+
+QLabel#loginFieldLabel {
+ color: #b5bac1;
+ font-size: 12px;
+ font-weight: 700;
+ text-transform: uppercase;
}
)";
diff --git a/client-cpp-gui/src/ui/theme.cpp.bak b/client-cpp-gui/src/ui/theme.cpp.bak
new file mode 100644
index 0000000..038d615
--- /dev/null
+++ b/client-cpp-gui/src/ui/theme.cpp.bak
@@ -0,0 +1,518 @@
+#include "blackwire/ui/theme.hpp"
+
+#include
+#include
+#include
+
+namespace blackwire {
+
+void ApplyAppTheme(QApplication& app) {
+ QPalette palette;
+ palette.setColor(QPalette::Window, QColor("#1e1f22"));
+ palette.setColor(QPalette::WindowText, QColor("#f2f3f5"));
+ palette.setColor(QPalette::Base, QColor("#2b2d31"));
+ palette.setColor(QPalette::AlternateBase, QColor("#313338"));
+ palette.setColor(QPalette::ToolTipBase, QColor("#313338"));
+ palette.setColor(QPalette::ToolTipText, QColor("#f2f3f5"));
+ palette.setColor(QPalette::Text, QColor("#f2f3f5"));
+ palette.setColor(QPalette::Button, QColor("#313338"));
+ palette.setColor(QPalette::ButtonText, QColor("#f2f3f5"));
+ palette.setColor(QPalette::BrightText, QColor("#ffffff"));
+ palette.setColor(QPalette::Link, QColor("#5865f2"));
+ palette.setColor(QPalette::Highlight, QColor("#5865f2"));
+ palette.setColor(QPalette::HighlightedText, QColor("#ffffff"));
+ app.setPalette(palette);
+
+ const QString qss = R"(
+QWidget {
+ background: #2b2d31;
+ color: #f2f3f5;
+ font-size: 13px;
+}
+
+QMainWindow, QDialog {
+ background: #1e1f22;
+}
+
+QStatusBar {
+ background: #1e1f22;
+ color: #b5bac1;
+ border-top: 1px solid #24262a;
+}
+
+QLineEdit, QPlainTextEdit, QListWidget, QComboBox {
+ background: #1e1f22;
+ border: 1px solid #1b1c20;
+ border-radius: 8px;
+ color: #f2f3f5;
+ selection-background-color: #5865f2;
+ padding: 6px 8px;
+}
+
+QComboBox#statusCombo {
+ min-width: 96px;
+}
+
+QLineEdit:focus, QPlainTextEdit:focus, QComboBox:focus {
+ border-color: #5865f2;
+}
+
+QComboBox::drop-down {
+ border: none;
+ width: 20px;
+}
+
+QPushButton {
+ border-radius: 8px;
+ padding: 6px 12px;
+ background: #3f4248;
+ color: #f2f3f5;
+ border: 1px solid #24262a;
+}
+
+QPushButton:hover {
+ background: #4a4d54;
+}
+
+QPushButton:pressed {
+ background: #35383e;
+}
+
+QPushButton:disabled {
+ color: #7c8088;
+ background: #2c2f34;
+}
+
+QPushButton#primaryButton {
+ background: #5865f2;
+ border: 1px solid #4a57d8;
+ font-weight: 600;
+}
+
+QPushButton#primaryButton:hover {
+ background: #4e5ada;
+}
+
+QPushButton#primaryButton:pressed {
+ background: #434dc0;
+}
+
+QPushButton#secondaryButton {
+ background: #3a3d44;
+}
+
+QPushButton#secondaryButton:checked {
+ background: #4e5ada;
+ border: 1px solid #4a57d8;
+ color: #ffffff;
+}
+
+QPushButton#dangerButton {
+ background: #da373c;
+ border: 1px solid #b32f33;
+ color: #ffffff;
+ font-weight: 600;
+}
+
+QPushButton#dangerButton:hover {
+ background: #c63237;
+}
+
+QWidget#dmSidebar {
+ background: #2b2d31;
+ border-right: 1px solid #1b1c20;
+}
+
+QWidget#settingsSidebar {
+ background: #232428;
+ border-right: 1px solid #1b1c20;
+}
+
+QListWidget#settingsTabList {
+ background: transparent;
+ border: none;
+ padding: 2px;
+}
+
+QListWidget#settingsTabList::item {
+ color: #b5bac1;
+ border-radius: 8px;
+ padding: 9px 10px;
+ margin: 1px 0px;
+}
+
+QListWidget#settingsTabList::item:hover {
+ background: #2e3035;
+ color: #eef0f3;
+}
+
+QListWidget#settingsTabList::item:selected {
+ background: #3a3d45;
+ color: #ffffff;
+ font-weight: 600;
+}
+
+QLabel#dmSidebarTitle {
+ font-size: 14px;
+ font-weight: 700;
+ color: #ffffff;
+}
+
+QLabel#contactsSidebarTitle {
+ font-size: 13px;
+ font-weight: 700;
+ color: #d6d9de;
+}
+
+QWidget#identityCard {
+ background: #232428;
+ border: 1px solid #1b1c20;
+ border-radius: 10px;
+}
+
+QWidget#contactsToggleCard {
+ background: #232428;
+ border: 1px solid #1b1c20;
+ border-radius: 10px;
+}
+
+QLabel#identityLabel {
+ color: #d6d9de;
+}
+
+QWidget#chatPane {
+ background: #313338;
+}
+
+QLabel#threadTitle, QLineEdit#threadTitle {
+ font-size: 15px;
+ font-weight: 700;
+ color: #ffffff;
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 6px;
+ padding: 2px 6px;
+}
+
+QLineEdit#threadTitle:focus {
+ border-color: #5865f2;
+ background: #232428;
+}
+
+QLabel#connectionPill {
+ border-radius: 10px;
+ padding: 2px 8px;
+ background: #3f4248;
+ color: #d6d9de;
+ border: 1px solid #2b2d31;
+}
+
+QLabel#connectionPill[state=\"connected\"] {
+ background: #2d7d46;
+ border-color: #236438;
+}
+
+QLabel#connectionPill[state=\"warning\"] {
+ background: #9a6a1b;
+ border-color: #7e5615;
+}
+
+QLabel#connectionPill[state=\"error\"] {
+ background: #9e2c31;
+ border-color: #7f2428;
+}
+
+QLabel#callStatusPill {
+ border-radius: 10px;
+ padding: 2px 8px;
+ background: #3f4248;
+ color: #d6d9de;
+ border: 1px solid #2b2d31;
+}
+
+QLabel#callStatusPill[state=\"ringing\"] {
+ background: #9a6a1b;
+ border-color: #7e5615;
+}
+
+QLabel#callStatusPill[state=\"active\"] {
+ background: #2d7d46;
+ border-color: #236438;
+}
+
+QLabel#callStatusPill[state=\"warning\"] {
+ background: #9a6a1b;
+ border-color: #7e5615;
+}
+
+QLabel#callStatusPill[state=\"error\"] {
+ background: #9e2c31;
+ border-color: #7f2428;
+}
+
+QWidget#callPanel {
+ background: #23262c;
+ border: 1px solid #1b1c20;
+ border-radius: 12px;
+}
+
+QWidget#callPanel[state=\"ringing\"] {
+ border-color: #9a6a1b;
+ background: #2d2921;
+}
+
+QWidget#callPanel[state=\"active\"] {
+ border-color: #236438;
+ background: #1f2d24;
+}
+
+QWidget#callPanel[state=\"warning\"] {
+ border-color: #9a6a1b;
+}
+
+QWidget#callPanel[state=\"error\"] {
+ border-color: #7f2428;
+}
+
+QLabel#callPanelAvatar {
+ background: #5865f2;
+ border-radius: 28px;
+ color: #ffffff;
+ font-size: 22px;
+ font-weight: 700;
+}
+
+QLabel#callPanelTitle {
+ color: #ffffff;
+ font-size: 16px;
+ font-weight: 700;
+}
+
+QLabel#callPanelSubtitle {
+ color: #c6cbd2;
+}
+
+QLabel#callParticipantChip {
+ background: #31353d;
+ border: 1px solid #1b1c20;
+ border-radius: 10px;
+ padding: 3px 8px;
+ color: #d6d9de;
+ font-size: 12px;
+}
+
+QLabel#callParticipantChip[self=\"true\"] {
+ background: #4250c7;
+ border-color: #4a57d8;
+ color: #ffffff;
+ font-weight: 600;
+}
+
+QLabel#chatBanner {
+ border-radius: 8px;
+ padding: 8px 10px;
+ color: #ffffff;
+ background: #3f4248;
+}
+
+QLabel#chatBanner[severity=\"info\"] {
+ background: #3756d9;
+}
+
+QLabel#chatBanner[severity=\"warning\"] {
+ background: #9a6a1b;
+}
+
+QLabel#chatBanner[severity=\"error\"] {
+ background: #da373c;
+}
+
+QListWidget#conversationList {
+ background: #2b2d31;
+ border: 1px solid #1b1c20;
+}
+
+QListWidget#contactsList {
+ background: #2b2d31;
+ border: 1px solid #1b1c20;
+}
+
+QListWidget#conversationList::item {
+ border-radius: 8px;
+}
+
+QListWidget#conversationList::item:selected {
+ background: #404249;
+}
+
+QListWidget#contactsList::item {
+ border-radius: 8px;
+}
+
+QListWidget#contactsList::item:selected {
+ background: #404249;
+}
+
+QWidget#conversationRow {
+ background: transparent;
+ border-radius: 8px;
+}
+
+QWidget#conversationRow[selected=\"true\"] {
+ background: #404249;
+}
+
+QLabel#conversationTitle {
+ color: #f2f3f5;
+ font-weight: 600;
+}
+
+QLabel#conversationSubtitle {
+ color: #b5bac1;
+}
+
+QLabel#conversationTime {
+ color: #9aa0aa;
+ font-size: 11px;
+}
+
+QPushButton#conversationRemoveButton {
+ min-width: 18px;
+ max-width: 18px;
+ min-height: 18px;
+ max-height: 18px;
+ border-radius: 9px;
+ border: 1px solid #2e3138;
+ background: #2c2f35;
+ color: #cfd3da;
+ padding: 0px;
+ font-weight: 600;
+}
+
+QPushButton#conversationRemoveButton:hover {
+ background: #c63237;
+ border-color: #b32f33;
+ color: #ffffff;
+}
+
+QLabel#presenceDot {
+ border-radius: 5px;
+ min-width: 10px;
+ max-width: 10px;
+ min-height: 10px;
+ max-height: 10px;
+ background: #6a6f78;
+}
+
+QLabel#presenceDot[state=\"active\"] {
+ background: #2d7d46;
+}
+
+QLabel#presenceDot[state=\"inactive\"] {
+ background: #d4a63c;
+}
+
+QLabel#presenceDot[state=\"offline\"] {
+ background: #6a6f78;
+}
+
+QLabel#presenceDot[state=\"dnd\"] {
+ background: #9e2c31;
+}
+
+QListWidget#messageList {
+ background: #1f2025;
+ border: none;
+}
+
+QListWidget#messageList::item {
+ background: transparent;
+ border: none;
+ margin: 0px;
+ padding: 0px;
+}
+
+QWidget#messageRow {
+ background: transparent;
+}
+
+QLabel#messageAvatar {
+ min-width: 32px;
+ max-width: 32px;
+ min-height: 32px;
+ max-height: 32px;
+ border-radius: 16px;
+ background: #3f4248;
+ border: 1px solid #2b2d31;
+ color: #f2f3f5;
+ font-size: 12px;
+ font-weight: 700;
+}
+
+QLabel#messageAvatar[grouped=\"true\"] {
+ background: transparent;
+ border-color: transparent;
+ color: transparent;
+}
+
+QWidget#messageBubble {
+ border-radius: 10px;
+ background: #24262d;
+ border: 1px solid #2f323b;
+}
+
+QLabel#messageMeta {
+ color: #ffffff;
+ font-size: 11px;
+ font-weight: 600;
+}
+
+QLabel#messageBody {
+ color: #f2f3f5;
+ line-height: 1.35em;
+}
+
+QWidget#systemMessageRow {
+ background: transparent;
+}
+
+QLabel#systemMessageIcon {
+ border-radius: 11px;
+ padding: 2px 8px;
+ background: #1f3f31;
+ border: 1px solid #2d7d46;
+ color: #56c177;
+ font-size: 10px;
+ font-weight: 700;
+}
+
+QLabel#systemMessageText {
+ color: #b5bac1;
+ font-size: 12px;
+}
+
+QLabel#chatEmptyState {
+ color: #b5bac1;
+ font-size: 14px;
+}
+
+QProgressBar#audioLevelMeter {
+ border: 1px solid #1b1c20;
+ border-radius: 6px;
+ background: #1e1f22;
+}
+
+QProgressBar#audioLevelMeter::chunk {
+ border-radius: 6px;
+ background: #3ba55d;
+}
+
+QDialog QLabel {
+ color: #d6d9de;
+}
+)";
+
+ app.setStyleSheet(qss);
+}
+
+} // namespace blackwire
diff --git a/client-cpp-gui/src/util/message_view.cpp b/client-cpp-gui/src/util/message_view.cpp
index a8e2a5e..f5b0a53 100644
--- a/client-cpp-gui/src/util/message_view.cpp
+++ b/client-cpp-gui/src/util/message_view.cpp
@@ -8,6 +8,8 @@ namespace blackwire {
namespace {
+const char* kFileMessagePrefix = "bwfile://v1:";
+
QDateTime ParseMessageTime(const std::string& value) {
const QString iso = QString::fromStdString(value);
QDateTime parsed = QDateTime::fromString(iso, Qt::ISODateWithMs);
@@ -95,8 +97,11 @@ std::vector BuildThreadMessageViews(
view.id = QString::fromStdString(item->id);
view.created_at_iso = QString::fromStdString(item->created_at);
view.created_at_display = FormatThreadTimestamp(view.created_at_iso);
- view.outgoing = item->sender_user_id == self_user_id;
- if (view.outgoing) {
+ view.system = item->sender_user_id.empty() && item->sender_address.empty();
+ view.outgoing = !view.system && item->sender_user_id == self_user_id;
+ if (view.system) {
+ view.sender_label.clear();
+ } else if (view.outgoing) {
view.sender_label = "You";
} else {
const QString from_address = LabelFromSenderAddress(item->sender_address);
@@ -107,8 +112,23 @@ std::vector BuildThreadMessageViews(
const QString plaintext = QString::fromStdString(item->plaintext);
view.body = plaintext.isEmpty() ? ExtractLegacyPlaintext(QString::fromStdString(item->rendered_text)) : plaintext;
- view.grouped_with_previous =
- !views.empty() && views.back().outgoing == view.outgoing && views.back().sender_label == view.sender_label;
+ view.sent_at_ms = item->sent_at_ms;
+ view.attachment_name = QString::fromStdString(item->attachment_name);
+ view.attachment_mime_type = QString::fromStdString(item->attachment_mime_type);
+ view.attachment_media_kind = QString::fromStdString(item->attachment_media_kind);
+ view.attachment_status = QString::fromStdString(item->attachment_status.empty() ? "success" : item->attachment_status);
+ view.attachment_retryable = !item->retry_payload.empty();
+ if (view.system) {
+ view.render_mode = "system";
+ view.grouped_with_previous = false;
+ } else {
+ view.render_mode = view.body.startsWith(kFileMessagePrefix, Qt::CaseInsensitive) ? "attachment" : "markdown";
+ view.grouped_with_previous =
+ !views.empty() &&
+ !views.back().system &&
+ views.back().outgoing == view.outgoing &&
+ views.back().sender_label == view.sender_label;
+ }
views.push_back(view);
}
diff --git a/client-cpp-gui/src/ws/qt_ws_client.cpp b/client-cpp-gui/src/ws/qt_ws_client.cpp
index ae2fe91..46aea21 100644
--- a/client-cpp-gui/src/ws/qt_ws_client.cpp
+++ b/client-cpp-gui/src/ws/qt_ws_client.cpp
@@ -140,6 +140,8 @@ void QtWsClient::SetHandlers(
CallWebRtcAnswerHandler on_call_webrtc_answer,
CallWebRtcIceHandler on_call_webrtc_ice,
GroupRenamedHandler on_group_renamed,
+ ConversationTypingHandler on_conversation_typing,
+ ConversationReadHandler on_conversation_read,
ErrorHandler on_error,
StatusHandler on_status) {
on_message_ = std::move(on_message);
@@ -156,6 +158,8 @@ void QtWsClient::SetHandlers(
on_call_webrtc_answer_ = std::move(on_call_webrtc_answer);
on_call_webrtc_ice_ = std::move(on_call_webrtc_ice);
on_group_renamed_ = std::move(on_group_renamed);
+ on_conversation_typing_ = std::move(on_conversation_typing);
+ on_conversation_read_ = std::move(on_conversation_read);
on_error_ = std::move(on_error);
on_status_ = std::move(on_status);
}
@@ -342,6 +346,22 @@ void QtWsClient::HandleTextMessage(const QString& message_text) {
return;
}
+ if (type == "conversation.typing") {
+ WsEventConversationTyping event = payload.get();
+ if (on_conversation_typing_) {
+ on_conversation_typing_(event);
+ }
+ return;
+ }
+
+ if (type == "conversation.read") {
+ WsEventConversationRead event = payload.get();
+ if (on_conversation_read_) {
+ on_conversation_read_(event);
+ }
+ return;
+ }
+
if (type == "call.audio") {
WsEventCallAudio event = payload.get();
if (on_call_audio_) {
diff --git a/client-cpp-gui/tests/test_envelope_serialization.cpp b/client-cpp-gui/tests/test_envelope_serialization.cpp
index 539bd42..e78fe1c 100644
--- a/client-cpp-gui/tests/test_envelope_serialization.cpp
+++ b/client-cpp-gui/tests/test_envelope_serialization.cpp
@@ -108,3 +108,51 @@ TEST(EnvelopeSerializationTest, ConversationMemberOutParsesNullableFields) {
EXPECT_TRUE(member.joined_at.empty());
EXPECT_TRUE(member.left_at.empty());
}
+
+TEST(EnvelopeSerializationTest, ConversationTypingResponseParsesV03bFields) {
+ const auto json = nlohmann::json::parse(R"({"ok":true,"expires_in_ms":6000})");
+ const auto response = json.get();
+ EXPECT_TRUE(response.ok);
+ EXPECT_EQ(response.expires_in_ms, 6000);
+}
+
+TEST(EnvelopeSerializationTest, ConversationReadStateParsesCursorList) {
+ const auto json = nlohmann::json::parse(
+ R"({"conversation_id":"conv-1","cursors":[{"user_address":"alice@local.invalid","last_read_message_id":"msg-123","last_read_sent_at_ms":42,"updated_at":"2026-03-01T10:00:00Z"}]})");
+ const auto state = json.get();
+ ASSERT_EQ(state.cursors.size(), 1U);
+ EXPECT_EQ(state.conversation_id, "conv-1");
+ EXPECT_EQ(state.cursors.front().user_address, "alice@local.invalid");
+ EXPECT_EQ(state.cursors.front().last_read_message_id, "msg-123");
+ EXPECT_EQ(state.cursors.front().last_read_sent_at_ms, 42);
+}
+
+TEST(EnvelopeSerializationTest, SystemVersionParsesServerAndBuildInfo) {
+ const auto json = nlohmann::json::parse(
+ R"({"server_version":"0.3.0","api_version":"v2","git_commit":"abc1234","build_timestamp":"2026-03-01T00:00:00Z"})");
+ const auto version = json.get();
+ EXPECT_EQ(version.server_version, "0.3.0");
+ EXPECT_EQ(version.api_version, "v2");
+ EXPECT_EQ(version.git_commit, "abc1234");
+ EXPECT_EQ(version.build_timestamp, "2026-03-01T00:00:00Z");
+}
+
+TEST(EnvelopeSerializationTest, ConversationTypingWsEventParses) {
+ const auto json = nlohmann::json::parse(
+ R"({"type":"conversation.typing","conversation_id":"conv-1","from_user_address":"bob@local.invalid","state":"on","expires_in_ms":6000,"sent_at":"2026-03-01T10:00:00Z"})");
+ const auto event = json.get();
+ EXPECT_EQ(event.conversation_id, "conv-1");
+ EXPECT_EQ(event.from_user_address, "bob@local.invalid");
+ EXPECT_EQ(event.state, "on");
+ EXPECT_EQ(event.expires_in_ms, 6000);
+}
+
+TEST(EnvelopeSerializationTest, ConversationReadWsEventParses) {
+ const auto json = nlohmann::json::parse(
+ R"({"type":"conversation.read","conversation_id":"conv-1","reader_user_address":"bob@local.invalid","last_read_message_id":"msg-123","last_read_sent_at_ms":42,"updated_at":"2026-03-01T10:00:00Z"})");
+ const auto event = json.get();
+ EXPECT_EQ(event.conversation_id, "conv-1");
+ EXPECT_EQ(event.reader_user_address, "bob@local.invalid");
+ EXPECT_EQ(event.last_read_message_id, "msg-123");
+ EXPECT_EQ(event.last_read_sent_at_ms, 42);
+}
diff --git a/client-cpp-gui/tests/test_message_view.cpp b/client-cpp-gui/tests/test_message_view.cpp
index d365f3f..cec455a 100644
--- a/client-cpp-gui/tests/test_message_view.cpp
+++ b/client-cpp-gui/tests/test_message_view.cpp
@@ -96,3 +96,66 @@ TEST(MessageViewTest, OrdersThreadChronologicallySoLatestIsAtBottom) {
EXPECT_EQ(views[1].body.toStdString(), "middle");
EXPECT_EQ(views[2].body.toStdString(), "latest");
}
+
+TEST(MessageViewTest, UsesAttachmentRenderModeForBwfileMessages) {
+ blackwire::LocalMessage attachment;
+ attachment.id = "a1";
+ attachment.sender_user_id = "self-id";
+ attachment.created_at = "2026-03-01T10:00:00Z";
+ attachment.plaintext = "bwfile://v1:eyJuYW1lIjoiZmlsZS50eHQiLCJkYXRhX2I2NCI6IlptOXYifQ==";
+
+ std::vector input = {attachment};
+ const auto views = blackwire::BuildThreadMessageViews(input, "self-id", "peer");
+
+ ASSERT_EQ(views.size(), 1U);
+ EXPECT_EQ(views[0].render_mode.toStdString(), "attachment");
+}
+
+TEST(MessageViewTest, UsesMarkdownRenderModeForPlainMessages) {
+ blackwire::LocalMessage plain;
+ plain.id = "m1";
+ plain.sender_user_id = "self-id";
+ plain.created_at = "2026-03-01T10:00:00Z";
+ plain.plaintext = "**hello** _world_";
+
+ std::vector input = {plain};
+ const auto views = blackwire::BuildThreadMessageViews(input, "self-id", "peer");
+
+ ASSERT_EQ(views.size(), 1U);
+ EXPECT_EQ(views[0].render_mode.toStdString(), "markdown");
+}
+
+TEST(MessageViewTest, SurfacesAttachmentStatusAndRetryability) {
+ blackwire::LocalMessage outgoing;
+ outgoing.id = "m1";
+ outgoing.sender_user_id = "self-id";
+ outgoing.created_at = "2026-03-01T10:00:00Z";
+ outgoing.plaintext = "bwfile://v1:eyJuYW1lIjoiYmlnLm1wNCIsImRhdGFfYjY0IjoiWm05diJ9";
+ outgoing.attachment_status = "failed";
+ outgoing.retry_payload = R"({"file_path":"C:\\tmp\\big.mp4"})";
+
+ std::vector input = {outgoing};
+ const auto views = blackwire::BuildThreadMessageViews(input, "self-id", "peer");
+
+ ASSERT_EQ(views.size(), 1U);
+ EXPECT_EQ(views[0].attachment_status.toStdString(), "failed");
+ EXPECT_TRUE(views[0].attachment_retryable);
+}
+
+TEST(MessageViewTest, TreatsSenderlessMessagesAsSystemMessages) {
+ blackwire::LocalMessage system;
+ system.id = "sys-1";
+ system.created_at = "2026-03-01T10:00:00Z";
+ system.plaintext = "alice started a call that lasted 2 minutes.";
+ system.sender_user_id.clear();
+ system.sender_address.clear();
+
+ std::vector input = {system};
+ const auto views = blackwire::BuildThreadMessageViews(input, "self-id", "peer");
+
+ ASSERT_EQ(views.size(), 1U);
+ EXPECT_TRUE(views[0].system);
+ EXPECT_EQ(views[0].render_mode.toStdString(), "system");
+ EXPECT_TRUE(views[0].sender_label.isEmpty());
+ EXPECT_FALSE(views[0].outgoing);
+}
diff --git a/client-cpp-gui/tests/test_state_store_compat.cpp b/client-cpp-gui/tests/test_state_store_compat.cpp
index cba723c..e0ffa41 100644
--- a/client-cpp-gui/tests/test_state_store_compat.cpp
+++ b/client-cpp-gui/tests/test_state_store_compat.cpp
@@ -130,3 +130,71 @@ TEST(StateCompatTest, ConversationMetaPeerAddressRoundTrip) {
EXPECT_EQ(restored.peer_username, "alice");
EXPECT_EQ(restored.peer_address, "alice@peer.onion");
}
+
+TEST(StateCompatTest, LoadsLegacyLocalMessageWithoutAttachmentFields) {
+ const auto json = nlohmann::json::parse(
+ R"({"id":"m4","conversation_id":"c4","sender_user_id":"u4","created_at":"2026-03-01T09:00:00Z","rendered_text":"[2026] Peer: file"})");
+
+ const auto message = json.get();
+ EXPECT_TRUE(message.attachment_name.empty());
+ EXPECT_TRUE(message.attachment_mime_type.empty());
+ EXPECT_TRUE(message.attachment_media_kind.empty());
+ EXPECT_EQ(message.attachment_status, "success");
+ EXPECT_TRUE(message.retry_payload.empty());
+}
+
+TEST(StateCompatTest, LoadsLocalMessageWithNullOptionalFields) {
+ // Reproduces the crash: to_json writes null for empty optional strings,
+ // but from_json used j.value() which throws type_error.302 on null.
+ const auto json = nlohmann::json::parse(
+ R"({"id":"m6","conversation_id":"c6","sender_user_id":"u6",
+ "sender_address":null,"created_at":"2026-03-01T10:00:00Z",
+ "sent_at_ms":0,"rendered_text":"[encrypted]",
+ "plaintext_cache_b64":null,"attachment_name":null,
+ "attachment_mime_type":null,"attachment_media_kind":null,
+ "attachment_status":"success","retry_payload":null})");
+
+ const auto message = json.get();
+ EXPECT_EQ(message.id, "m6");
+ EXPECT_TRUE(message.sender_address.empty());
+ EXPECT_TRUE(message.plaintext_cache_b64.empty());
+ EXPECT_TRUE(message.attachment_name.empty());
+ EXPECT_TRUE(message.attachment_mime_type.empty());
+ EXPECT_TRUE(message.attachment_media_kind.empty());
+ EXPECT_TRUE(message.retry_payload.empty());
+}
+
+TEST(StateCompatTest, SaveMessageCacheDefaultsToTrue) {
+ const auto json = nlohmann::json::parse(
+ R"({"base_url":"http://localhost:8000","has_user":false,"has_device":false,"conversations":[]})");
+
+ const auto state = json.get();
+ EXPECT_TRUE(state.social_preferences.save_message_cache);
+}
+
+TEST(StateCompatTest, PersistsAttachmentMetadataAndRetryPayloadRoundTrip) {
+ blackwire::LocalMessage message;
+ message.id = "m5";
+ message.conversation_id = "c5";
+ message.sender_user_id = "u5";
+ message.created_at = "2026-03-01T09:15:00Z";
+ message.attachment_name = "photo.jpg";
+ message.attachment_mime_type = "image/jpeg";
+ message.attachment_media_kind = "image";
+ message.attachment_status = "failed";
+ message.retry_payload = R"({"conversation_id":"c5","file_path":"C:\\tmp\\photo.jpg"})";
+
+ const nlohmann::json json = message;
+ EXPECT_EQ(json.at("attachment_name").get(), "photo.jpg");
+ EXPECT_EQ(json.at("attachment_mime_type").get(), "image/jpeg");
+ EXPECT_EQ(json.at("attachment_media_kind").get(), "image");
+ EXPECT_EQ(json.at("attachment_status").get(), "failed");
+ EXPECT_EQ(json.at("retry_payload").get(), R"({"conversation_id":"c5","file_path":"C:\\tmp\\photo.jpg"})");
+
+ const auto restored = json.get();
+ EXPECT_EQ(restored.attachment_name, "photo.jpg");
+ EXPECT_EQ(restored.attachment_mime_type, "image/jpeg");
+ EXPECT_EQ(restored.attachment_media_kind, "image");
+ EXPECT_EQ(restored.attachment_status, "failed");
+ EXPECT_EQ(restored.retry_payload, R"({"conversation_id":"c5","file_path":"C:\\tmp\\photo.jpg"})");
+}
diff --git a/server/app/api_v2/__init__.py b/server/app/api_v2/__init__.py
index 3f37e1d..dffc157 100644
--- a/server/app/api_v2/__init__.py
+++ b/server/app/api_v2/__init__.py
@@ -1,3 +1,3 @@
-from app.api_v2 import auth, conversations, devices, federation, messages, presence, users
+from app.api_v2 import auth, conversations, devices, federation, keys, messages, presence, system, users
-__all__ = ["auth", "conversations", "devices", "federation", "messages", "presence", "users"]
+__all__ = ["auth", "conversations", "devices", "federation", "keys", "messages", "presence", "system", "users"]
diff --git a/server/app/api_v2/conversations.py b/server/app/api_v2/conversations.py
index 949588f..e816f90 100644
--- a/server/app/api_v2/conversations.py
+++ b/server/app/api_v2/conversations.py
@@ -4,9 +4,14 @@
from app.api.utils import client_rate_limit_key
from app.dependencies import AuthenticatedDeviceContextV2, db_session, get_current_device_context_v2
from app.schemas.v2_conversation import (
+ ConversationReadCursorOutV2,
+ ConversationReadRequestV2,
+ ConversationReadStateOutV2,
ConversationMemberOutV2,
ConversationOutV2,
ConversationRecipientsOutV2,
+ ConversationTypingRequestV2,
+ ConversationTypingResponseV2,
CreateDMConversationRequestV2,
CreateGroupConversationRequestV2,
GroupInviteRequestV2,
@@ -17,7 +22,9 @@
from app.services.conversation_service import conversation_service
from app.services.group_conversation_service import group_conversation_service
from app.services.message_service_v2 import message_service_v2
+from app.services.read_state_service_v2 import read_state_service_v2
from app.services.rate_limit import rate_limiter
+from app.services.typing_service_v2 import typing_service_v2
router = APIRouter(prefix="/api/v2/conversations", tags=["conversations-v2"])
@@ -122,6 +129,72 @@ async def list_members(
return [group_conversation_service.member_out(row) for row in members]
+@router.post("/{conversation_id}/typing", response_model=ConversationTypingResponseV2)
+async def publish_typing(
+ conversation_id: str,
+ payload: ConversationTypingRequestV2,
+ request: Request,
+ session: AsyncSession = Depends(db_session),
+ context: AuthenticatedDeviceContextV2 = Depends(get_current_device_context_v2),
+) -> ConversationTypingResponseV2:
+ await rate_limiter.enforce(
+ client_rate_limit_key(request, f"v2-typing:{context.user.id}"),
+ limit=typing_service_v2.settings.typing_event_rate_per_minute,
+ )
+ conversation = await conversation_service.get_by_id(session, conversation_id)
+ if conversation is None:
+ raise HTTPException(status_code=404, detail="Conversation not found")
+ expires_in_ms = await typing_service_v2.publish_local_typing(
+ session,
+ conversation=conversation,
+ sender_user=context.user,
+ state=payload.state,
+ )
+ return ConversationTypingResponseV2(ok=True, expires_in_ms=expires_in_ms)
+
+
+@router.post("/{conversation_id}/read", response_model=ConversationReadCursorOutV2)
+async def publish_read(
+ conversation_id: str,
+ payload: ConversationReadRequestV2,
+ request: Request,
+ session: AsyncSession = Depends(db_session),
+ context: AuthenticatedDeviceContextV2 = Depends(get_current_device_context_v2),
+) -> ConversationReadCursorOutV2:
+ await rate_limiter.enforce(
+ client_rate_limit_key(request, f"v2-read-cursor:{context.user.id}"),
+ limit=read_state_service_v2.settings.read_cursor_write_rate_per_minute,
+ )
+ conversation = await conversation_service.get_by_id(session, conversation_id)
+ if conversation is None:
+ raise HTTPException(status_code=404, detail="Conversation not found")
+ return await read_state_service_v2.publish_local_read_cursor(
+ session,
+ conversation=conversation,
+ reader_user=context.user,
+ last_read_message_id=payload.last_read_message_id,
+ last_read_sent_at_ms=payload.last_read_sent_at_ms,
+ )
+
+
+@router.get("/{conversation_id}/read", response_model=ConversationReadStateOutV2)
+async def get_read(
+ conversation_id: str,
+ request: Request,
+ session: AsyncSession = Depends(db_session),
+ context: AuthenticatedDeviceContextV2 = Depends(get_current_device_context_v2),
+) -> ConversationReadStateOutV2:
+ await rate_limiter.enforce(client_rate_limit_key(request, f"v2-read-cursor-get:{context.user.id}"))
+ conversation = await conversation_service.get_by_id(session, conversation_id)
+ if conversation is None:
+ raise HTTPException(status_code=404, detail="Conversation not found")
+ return await read_state_service_v2.get_read_state(
+ session,
+ conversation=conversation,
+ reader_user=context.user,
+ )
+
+
@router.post("/{conversation_id}/members/invite", response_model=list[ConversationMemberOutV2])
async def invite_members(
conversation_id: str,
diff --git a/server/app/api_v2/federation.py b/server/app/api_v2/federation.py
index 0004a8a..e95af54 100644
--- a/server/app/api_v2/federation.py
+++ b/server/app/api_v2/federation.py
@@ -10,6 +10,8 @@
FederationCallWebRtcAnswerRequestV2,
FederationCallWebRtcIceRequestV2,
FederationCallWebRtcOfferRequestV2,
+ FederationConversationReadRelayRequestV2,
+ FederationConversationTypingRelayRequestV2,
FederationGroupCallEndRequestV2,
FederationGroupCallJoinRequestV2,
FederationGroupCallLeaveRequestV2,
@@ -32,12 +34,14 @@
from app.services.message_service_v2 import message_service_v2
from app.services.metrics import metrics
from app.services.prekey_service_v2 import prekey_service_v2
+from app.services.read_state_service_v2 import read_state_service_v2
from app.services.rate_limit import rate_limiter
from app.services.server_identity import (
get_federation_signing_public_key_b64,
get_server_onion,
server_address_for_username,
)
+from app.services.typing_service_v2 import typing_service_v2
router = APIRouter(prefix="/api/v2/federation", tags=["federation-v2"])
@@ -212,6 +216,28 @@ async def group_invite_accept(
return {"status": "ok"}
+@router.post("/conversations/typing")
+async def relay_conversation_typing(
+ request: Request,
+ session: AsyncSession = Depends(db_session),
+) -> dict[str, str]:
+ raw_body = await _verify_federation_write_auth(request, session)
+ payload = FederationConversationTypingRelayRequestV2.model_validate_json(raw_body)
+ await typing_service_v2.relay_typing_from_federation(session, payload)
+ return {"status": "ok"}
+
+
+@router.post("/conversations/read")
+async def relay_conversation_read(
+ request: Request,
+ session: AsyncSession = Depends(db_session),
+) -> dict[str, str]:
+ raw_body = await _verify_federation_write_auth(request, session)
+ payload = FederationConversationReadRelayRequestV2.model_validate_json(raw_body)
+ await read_state_service_v2.relay_read_from_federation(session, payload)
+ return {"status": "ok"}
+
+
@router.post("/group-calls/offer")
async def relay_group_call_offer(
request: Request,
diff --git a/server/app/api_v2/system.py b/server/app/api_v2/system.py
new file mode 100644
index 0000000..ccedc1c
--- /dev/null
+++ b/server/app/api_v2/system.py
@@ -0,0 +1,14 @@
+from fastapi import APIRouter, Depends
+
+from app.dependencies import AuthenticatedDeviceContextV2, get_current_device_context_v2
+from app.schemas.v2_system import SystemVersionOutV2
+from app.services.version_info import resolve_server_version_info
+
+router = APIRouter(prefix="/api/v2/system", tags=["system-v2"])
+
+
+@router.get("/version", response_model=SystemVersionOutV2)
+async def version_info(
+ _: AuthenticatedDeviceContextV2 = Depends(get_current_device_context_v2),
+) -> SystemVersionOutV2:
+ return SystemVersionOutV2(**resolve_server_version_info())
diff --git a/server/app/api_v2/ws.py b/server/app/api_v2/ws.py
index a6900be..674319e 100644
--- a/server/app/api_v2/ws.py
+++ b/server/app/api_v2/ws.py
@@ -297,7 +297,10 @@ async def send_call_error(code: str, detail: str) -> None:
"detail": (
"Supported client events: "
"message.ack, call.offer, call.accept, call.reject, call.end, "
- "call.audio, call.webrtc.offer, call.webrtc.answer, call.webrtc.ice"
+ "call.audio, call.webrtc.offer, call.webrtc.answer, call.webrtc.ice. "
+ "Typing and read updates are write-only over REST via "
+ "POST /api/v2/conversations/{conversation_id}/typing and "
+ "POST /api/v2/conversations/{conversation_id}/read."
),
}
)
diff --git a/server/app/config.py b/server/app/config.py
index 979f072..1f0a8b7 100644
--- a/server/app/config.py
+++ b/server/app/config.py
@@ -49,8 +49,13 @@ class Settings(BaseSettings):
voice_call_ring_timeout_seconds: int = 30
voice_audio_max_chunk_bytes: int = 4096
voice_audio_min_interval_ms: int = 8
- enable_group_dm_v2c: bool = False
- enable_group_call_v2c: bool = False
+ enable_group_dm_v2c: bool = True
+ enable_group_call_v2c: bool = True
+ enable_typing_v03b: bool = True
+ enable_read_cursor_v03b: bool = True
+ typing_indicator_ttl_seconds: int = 6
+ typing_event_rate_per_minute: int = 240
+ read_cursor_write_rate_per_minute: int = 240
group_max_members: int = 32
group_call_max_participants: int = 8
group_invite_rate_per_minute: int = 120
@@ -117,6 +122,12 @@ def validate_attachment_limits(self) -> "Settings":
raise ValueError("group_call_start_rate_per_minute must be greater than 0")
if self.group_call_ring_ttl_seconds <= 0:
raise ValueError("group_call_ring_ttl_seconds must be greater than 0")
+ if self.typing_indicator_ttl_seconds <= 0:
+ raise ValueError("typing_indicator_ttl_seconds must be greater than 0")
+ if self.typing_event_rate_per_minute <= 0:
+ raise ValueError("typing_event_rate_per_minute must be greater than 0")
+ if self.read_cursor_write_rate_per_minute <= 0:
+ raise ValueError("read_cursor_write_rate_per_minute must be greater than 0")
if self.attachment_inline_max_bytes > self.attachment_hard_ceiling_bytes:
raise ValueError("attachment_inline_max_bytes cannot exceed attachment_hard_ceiling_bytes")
if self.max_ciphertext_bytes > self.attachment_hard_ceiling_bytes:
diff --git a/server/app/main.py b/server/app/main.py
index bb42af6..8efb0cb 100644
--- a/server/app/main.py
+++ b/server/app/main.py
@@ -15,6 +15,7 @@
keys as keys_v2,
messages as messages_v2,
presence as presence_v2,
+ system as system_v2,
users as users_v2,
ws as ws_v2,
)
@@ -34,12 +35,14 @@
from app.services.message_service import message_service
from app.services.message_service_v2 import message_service_v2
from app.services.queue_worker import queue_cleanup_worker
+from app.services.read_state_service_v2 import read_state_service_v2
from app.services.rate_limit import rate_limiter
from app.services.server_identity import (
get_server_onion,
get_server_onion_source,
initialize_server_identity,
)
+from app.services.typing_service_v2 import typing_service_v2
from app.ws.manager import connection_manager
logger = logging.getLogger("blackwire.app")
@@ -89,6 +92,7 @@ def create_app() -> FastAPI:
app.include_router(conversations_v2.router)
app.include_router(messages_v2.router)
app.include_router(federation_v2.router)
+ app.include_router(system_v2.router)
@app.on_event("startup")
async def on_startup() -> None:
@@ -109,6 +113,8 @@ async def on_startup() -> None:
raise RuntimeError("BLACKWIRE_WEBRTC_ICE_SERVERS_JSON must be a non-empty JSON array")
initialize_server_identity(settings)
+ typing_service_v2.settings = settings
+ read_state_service_v2.settings = settings
server_onion = get_server_onion()
onion_source = get_server_onion_source()
logger.info(
diff --git a/server/app/models/__init__.py b/server/app/models/__init__.py
index 95a27c8..f5f7fe0 100644
--- a/server/app/models/__init__.py
+++ b/server/app/models/__init__.py
@@ -1,4 +1,5 @@
from app.models.conversation import Conversation
+from app.models.conversation_read_cursor import ConversationReadCursor
from app.models.conversation_member import ConversationMember
from app.models.delivery_queue import DeliveryQueue
from app.models.device import ActiveDevice, Device
@@ -23,6 +24,7 @@
"Device",
"ActiveDevice",
"Conversation",
+ "ConversationReadCursor",
"ConversationMember",
"GroupMembershipEvent",
"GroupCallSession",
diff --git a/server/app/models/conversation_read_cursor.py b/server/app/models/conversation_read_cursor.py
new file mode 100644
index 0000000..a99005f
--- /dev/null
+++ b/server/app/models/conversation_read_cursor.py
@@ -0,0 +1,29 @@
+import uuid
+from datetime import UTC, datetime
+
+from sqlalchemy import BigInteger, DateTime, ForeignKey, String, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.models.base import Base
+
+
+class ConversationReadCursor(Base):
+ __tablename__ = "conversation_read_cursors"
+ __table_args__ = (
+ UniqueConstraint("conversation_id", "user_id", name="uq_conversation_read_cursor_user"),
+ )
+
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
+ conversation_id: Mapped[str] = mapped_column(
+ ForeignKey("conversations.id", ondelete="CASCADE"),
+ index=True,
+ )
+ user_id: Mapped[str] = mapped_column(
+ ForeignKey("users.id", ondelete="CASCADE"),
+ index=True,
+ )
+ user_address: Mapped[str] = mapped_column(String(320), index=True)
+ last_read_message_id: Mapped[str] = mapped_column(String(36))
+ last_read_sent_at_ms: Mapped[int] = mapped_column(BigInteger, index=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), index=True)
diff --git a/server/app/schemas/v2_conversation.py b/server/app/schemas/v2_conversation.py
index b62e5ac..49003a0 100644
--- a/server/app/schemas/v2_conversation.py
+++ b/server/app/schemas/v2_conversation.py
@@ -42,6 +42,41 @@ class GroupLeaveRequestV2(BaseModel):
reason: str | None = Field(default=None, max_length=64)
+class ConversationTypingRequestV2(BaseModel):
+ state: Literal["on", "off"] = "on"
+ client_ts_ms: int | None = Field(default=None, ge=0)
+
+
+class ConversationTypingResponseV2(BaseModel):
+ ok: bool = True
+ expires_in_ms: int = Field(ge=1)
+
+
+class ConversationReadRequestV2(BaseModel):
+ last_read_message_id: str = Field(min_length=8, max_length=64)
+ last_read_sent_at_ms: int = Field(ge=0)
+
+
+class ConversationReadCursorOutV2(BaseModel):
+ conversation_id: str
+ reader_user_address: str
+ last_read_message_id: str
+ last_read_sent_at_ms: int
+ updated_at: datetime
+
+
+class ConversationReadCursorEntryOutV2(BaseModel):
+ user_address: str
+ last_read_message_id: str
+ last_read_sent_at_ms: int
+ updated_at: datetime
+
+
+class ConversationReadStateOutV2(BaseModel):
+ conversation_id: str
+ cursors: list[ConversationReadCursorEntryOutV2]
+
+
class ConversationOutV2(BaseModel):
model_config = ConfigDict(from_attributes=True)
diff --git a/server/app/schemas/v2_federation.py b/server/app/schemas/v2_federation.py
index d034c32..e74815b 100644
--- a/server/app/schemas/v2_federation.py
+++ b/server/app/schemas/v2_federation.py
@@ -73,6 +73,24 @@ class FederationGroupInviteAcceptRequestV2(BaseModel):
actor_address: str = Field(min_length=3, max_length=320)
+class FederationConversationTypingRelayRequestV2(BaseModel):
+ relay_id: str
+ conversation_id: str = Field(min_length=8, max_length=64)
+ from_user_address: str = Field(min_length=3, max_length=320)
+ state: str = Field(min_length=2, max_length=8)
+ expires_in_ms: int = Field(ge=1, le=60000)
+ sent_at: str = Field(min_length=8, max_length=128)
+
+
+class FederationConversationReadRelayRequestV2(BaseModel):
+ relay_id: str
+ conversation_id: str = Field(min_length=8, max_length=64)
+ reader_user_address: str = Field(min_length=3, max_length=320)
+ last_read_message_id: str = Field(min_length=8, max_length=64)
+ last_read_sent_at_ms: int = Field(ge=0)
+ updated_at: str = Field(min_length=8, max_length=128)
+
+
class FederationCallWebRtcOfferRequestV2(BaseModel):
relay_id: str
call_id: str
diff --git a/server/app/schemas/v2_system.py b/server/app/schemas/v2_system.py
new file mode 100644
index 0000000..9d92fa7
--- /dev/null
+++ b/server/app/schemas/v2_system.py
@@ -0,0 +1,8 @@
+from pydantic import BaseModel
+
+
+class SystemVersionOutV2(BaseModel):
+ server_version: str
+ api_version: str = "v2"
+ git_commit: str
+ build_timestamp: str
diff --git a/server/app/services/read_state_service_v2.py b/server/app/services/read_state_service_v2.py
new file mode 100644
index 0000000..a433056
--- /dev/null
+++ b/server/app/services/read_state_service_v2.py
@@ -0,0 +1,306 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from uuid import uuid4
+
+from fastapi import HTTPException, status
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.config import get_settings
+from app.models.conversation import Conversation
+from app.models.conversation_member import ConversationMember
+from app.models.conversation_read_cursor import ConversationReadCursor
+from app.models.message_event import MessageEvent
+from app.models.user import User
+from app.schemas.v2_conversation import (
+ ConversationReadCursorEntryOutV2,
+ ConversationReadCursorOutV2,
+ ConversationReadStateOutV2,
+)
+from app.schemas.v2_federation import FederationConversationReadRelayRequestV2
+from app.services.conversation_service import conversation_service
+from app.services.federation_outbox_service import federation_outbox_service
+from app.services.group_conversation_service import group_conversation_service
+from app.services.server_authority import is_local_server_authority
+from app.services.server_identity import server_address_for_username
+from app.ws.manager import connection_manager
+
+
+class ReadStateServiceV2:
+ def __init__(self) -> None:
+ self.settings = get_settings()
+
+ def _ensure_enabled(self) -> None:
+ if not self.settings.enable_read_cursor_v03b:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Read cursor is disabled")
+
+ async def _active_group_members(self, session: AsyncSession, conversation_id: str) -> list[ConversationMember]:
+ stmt = select(ConversationMember).where(
+ ConversationMember.conversation_id == conversation_id,
+ ConversationMember.status == "active",
+ )
+ return list((await session.execute(stmt)).scalars().all())
+
+ async def _ensure_read_access(self, session: AsyncSession, conversation: Conversation, user_id: str) -> None:
+ if conversation.conversation_type == "group":
+ await group_conversation_service.ensure_member_access(
+ session,
+ conversation,
+ user_id,
+ require_active=True,
+ )
+ return
+ conversation_service.ensure_membership(conversation, user_id)
+
+ async def _resolve_local_recipients(
+ self,
+ session: AsyncSession,
+ conversation: Conversation,
+ ) -> set[str]:
+ if conversation.conversation_type == "group":
+ members = await self._active_group_members(session, conversation.id)
+ return {row.member_user_id for row in members if row.member_user_id is not None}
+
+ if conversation.kind == "local":
+ recipients: set[str] = set()
+ if conversation.user_a_id:
+ recipients.add(conversation.user_a_id)
+ if conversation.user_b_id:
+ recipients.add(conversation.user_b_id)
+ return recipients
+
+ if conversation.local_user_id:
+ return {conversation.local_user_id}
+ return set()
+
+ async def _resolve_remote_peer_servers(
+ self,
+ session: AsyncSession,
+ conversation: Conversation,
+ ) -> set[str]:
+ remote_servers: set[str] = set()
+ if conversation.conversation_type == "group":
+ members = await self._active_group_members(session, conversation.id)
+ for member in members:
+ server_onion = (member.member_server_onion or "").strip().lower()
+ if not server_onion or is_local_server_authority(server_onion, self.settings):
+ continue
+ remote_servers.add(server_onion)
+ return remote_servers
+
+ if conversation.kind == "remote":
+ peer_onion = (conversation.peer_server_onion or "").strip().lower()
+ if peer_onion and not is_local_server_authority(peer_onion, self.settings):
+ remote_servers.add(peer_onion)
+ return remote_servers
+
+ async def _fanout_remote(
+ self,
+ session: AsyncSession,
+ *,
+ remote_servers: set[str],
+ payload: dict,
+ sender_key: str,
+ ) -> None:
+ for peer_onion in remote_servers:
+ outbox_item = await federation_outbox_service.enqueue(
+ session,
+ peer_onion=peer_onion,
+ event_type="conversation.read",
+ endpoint_path="/api/v2/federation/conversations/read",
+ payload_json=payload,
+ dedupe_key=f"conversation-read:{sender_key}:{peer_onion}",
+ )
+ await session.commit()
+ await federation_outbox_service.deliver_item(session, outbox_item.id)
+
+ async def _upsert_cursor(
+ self,
+ session: AsyncSession,
+ *,
+ conversation_id: str,
+ user_id: str,
+ user_address: str,
+ last_read_message_id: str,
+ last_read_sent_at_ms: int,
+ ) -> tuple[ConversationReadCursor, bool]:
+ stmt = select(ConversationReadCursor).where(
+ ConversationReadCursor.conversation_id == conversation_id,
+ ConversationReadCursor.user_id == user_id,
+ )
+ existing = (await session.execute(stmt)).scalar_one_or_none()
+ if existing is None:
+ created = ConversationReadCursor(
+ conversation_id=conversation_id,
+ user_id=user_id,
+ user_address=user_address,
+ last_read_message_id=last_read_message_id,
+ last_read_sent_at_ms=last_read_sent_at_ms,
+ created_at=datetime.now(UTC),
+ updated_at=datetime.now(UTC),
+ )
+ session.add(created)
+ await session.commit()
+ await session.refresh(created)
+ return created, True
+
+ if last_read_sent_at_ms < existing.last_read_sent_at_ms:
+ return existing, False
+ if (
+ last_read_sent_at_ms == existing.last_read_sent_at_ms
+ and last_read_message_id == existing.last_read_message_id
+ ):
+ return existing, False
+
+ existing.last_read_message_id = last_read_message_id
+ existing.last_read_sent_at_ms = last_read_sent_at_ms
+ existing.user_address = user_address
+ existing.updated_at = datetime.now(UTC)
+ await session.commit()
+ await session.refresh(existing)
+ return existing, True
+
+ @staticmethod
+ def _event_payload(cursor: ConversationReadCursor) -> dict:
+ return {
+ "type": "conversation.read",
+ "conversation_id": cursor.conversation_id,
+ "reader_user_address": cursor.user_address,
+ "last_read_message_id": cursor.last_read_message_id,
+ "last_read_sent_at_ms": cursor.last_read_sent_at_ms,
+ "updated_at": cursor.updated_at.isoformat(),
+ }
+
+ async def publish_local_read_cursor(
+ self,
+ session: AsyncSession,
+ *,
+ conversation: Conversation,
+ reader_user: User,
+ last_read_message_id: str,
+ last_read_sent_at_ms: int,
+ ) -> ConversationReadCursorOutV2:
+ self._ensure_enabled()
+ await self._ensure_read_access(session, conversation, reader_user.id)
+
+ message = (
+ await session.execute(select(MessageEvent).where(MessageEvent.id == last_read_message_id))
+ ).scalar_one_or_none()
+ if message is None:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Message not found")
+ if message.conversation_id != conversation.id:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Message does not belong to conversation")
+ if int(message.sent_at_ms) != int(last_read_sent_at_ms):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="last_read_sent_at_ms does not match message",
+ )
+
+ reader_address = server_address_for_username(reader_user.username)
+ cursor, changed = await self._upsert_cursor(
+ session,
+ conversation_id=conversation.id,
+ user_id=reader_user.id,
+ user_address=reader_address,
+ last_read_message_id=last_read_message_id,
+ last_read_sent_at_ms=last_read_sent_at_ms,
+ )
+ if changed:
+ event_payload = self._event_payload(cursor)
+ local_recipients = await self._resolve_local_recipients(session, conversation)
+ for user_id in local_recipients:
+ await connection_manager.send_to_user(user_id, event_payload)
+
+ remote_servers = await self._resolve_remote_peer_servers(session, conversation)
+ relay_payload = {
+ "relay_id": str(uuid4()),
+ "conversation_id": conversation.id,
+ "reader_user_address": reader_address,
+ "last_read_message_id": cursor.last_read_message_id,
+ "last_read_sent_at_ms": cursor.last_read_sent_at_ms,
+ "updated_at": cursor.updated_at.isoformat(),
+ }
+ await self._fanout_remote(
+ session,
+ remote_servers=remote_servers,
+ payload=relay_payload,
+ sender_key=f"{conversation.id}:{reader_user.id}:{cursor.last_read_sent_at_ms}",
+ )
+
+ return ConversationReadCursorOutV2(
+ conversation_id=cursor.conversation_id,
+ reader_user_address=cursor.user_address,
+ last_read_message_id=cursor.last_read_message_id,
+ last_read_sent_at_ms=cursor.last_read_sent_at_ms,
+ updated_at=cursor.updated_at,
+ )
+
+ async def get_read_state(
+ self,
+ session: AsyncSession,
+ *,
+ conversation: Conversation,
+ reader_user: User,
+ ) -> ConversationReadStateOutV2:
+ self._ensure_enabled()
+ await self._ensure_read_access(session, conversation, reader_user.id)
+ rows = list(
+ (
+ await session.execute(
+ select(ConversationReadCursor)
+ .where(ConversationReadCursor.conversation_id == conversation.id)
+ .order_by(ConversationReadCursor.updated_at.desc())
+ )
+ ).scalars()
+ )
+ return ConversationReadStateOutV2(
+ conversation_id=conversation.id,
+ cursors=[
+ ConversationReadCursorEntryOutV2(
+ user_address=row.user_address,
+ last_read_message_id=row.last_read_message_id,
+ last_read_sent_at_ms=row.last_read_sent_at_ms,
+ updated_at=row.updated_at,
+ )
+ for row in rows
+ ],
+ )
+
+ async def relay_read_from_federation(
+ self,
+ session: AsyncSession,
+ payload: FederationConversationReadRelayRequestV2,
+ ) -> None:
+ self._ensure_enabled()
+ conversation = await conversation_service.get_by_id(session, payload.conversation_id)
+ if conversation is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
+
+ reader_address = payload.reader_user_address.strip().lower()
+ recipients: set[str] = set()
+ if conversation.conversation_type == "group":
+ members = await self._active_group_members(session, conversation.id)
+ member_addresses = {row.member_address for row in members}
+ if reader_address not in member_addresses:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Read sender is not a group member")
+ recipients = {row.member_user_id for row in members if row.member_user_id is not None}
+ elif conversation.kind == "remote":
+ if reader_address != (conversation.peer_address or "").strip().lower():
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Read sender mismatch")
+ if conversation.local_user_id:
+ recipients.add(conversation.local_user_id)
+
+ event_payload = {
+ "type": "conversation.read",
+ "conversation_id": conversation.id,
+ "reader_user_address": reader_address,
+ "last_read_message_id": payload.last_read_message_id,
+ "last_read_sent_at_ms": payload.last_read_sent_at_ms,
+ "updated_at": payload.updated_at,
+ }
+ for user_id in recipients:
+ await connection_manager.send_to_user(user_id, event_payload)
+
+
+read_state_service_v2 = ReadStateServiceV2()
diff --git a/server/app/services/typing_service_v2.py b/server/app/services/typing_service_v2.py
new file mode 100644
index 0000000..46983d2
--- /dev/null
+++ b/server/app/services/typing_service_v2.py
@@ -0,0 +1,224 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime
+from uuid import uuid4
+
+from fastapi import HTTPException, status
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.config import get_settings
+from app.models.conversation import Conversation
+from app.models.conversation_member import ConversationMember
+from app.models.user import User
+from app.schemas.v2_federation import FederationConversationTypingRelayRequestV2
+from app.services.conversation_service import conversation_service
+from app.services.federation_outbox_service import federation_outbox_service
+from app.services.group_conversation_service import group_conversation_service
+from app.services.server_authority import is_local_server_authority
+from app.services.server_identity import server_address_for_username
+from app.ws.manager import connection_manager
+
+
+class TypingServiceV2:
+ def __init__(self) -> None:
+ self.settings = get_settings()
+
+ def _ensure_enabled(self) -> None:
+ if not self.settings.enable_typing_v03b:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Typing indicators are disabled")
+
+ async def _active_group_members(self, session: AsyncSession, conversation_id: str) -> list[ConversationMember]:
+ stmt = select(ConversationMember).where(
+ ConversationMember.conversation_id == conversation_id,
+ ConversationMember.status == "active",
+ )
+ return list((await session.execute(stmt)).scalars().all())
+
+ async def _local_recipient_user_ids(
+ self,
+ session: AsyncSession,
+ conversation: Conversation,
+ *,
+ sender_user_id: str | None,
+ sender_address: str,
+ ) -> set[str]:
+ if conversation.conversation_type == "group":
+ members = await self._active_group_members(session, conversation.id)
+ recipient_user_ids = {
+ row.member_user_id
+ for row in members
+ if row.member_user_id is not None and row.member_address != sender_address
+ }
+ return {row for row in recipient_user_ids if row is not None}
+
+ if conversation.kind == "local":
+ if sender_user_id is None:
+ return set()
+ conversation_service.ensure_membership(conversation, sender_user_id)
+ recipients: set[str] = set()
+ if conversation.user_a_id and conversation.user_a_id != sender_user_id:
+ recipients.add(conversation.user_a_id)
+ if conversation.user_b_id and conversation.user_b_id != sender_user_id:
+ recipients.add(conversation.user_b_id)
+ return recipients
+
+ if sender_user_id is not None:
+ conversation_service.ensure_membership(conversation, sender_user_id)
+ if conversation.local_user_id and sender_address != conversation.peer_address:
+ return {conversation.local_user_id}
+ return set()
+
+ async def _remote_peer_servers_for_typing(
+ self,
+ session: AsyncSession,
+ conversation: Conversation,
+ *,
+ sender_user_id: str,
+ sender_address: str,
+ ) -> set[str]:
+ remote_servers: set[str] = set()
+ if conversation.conversation_type == "group":
+ member = await group_conversation_service.ensure_member_access(
+ session,
+ conversation,
+ sender_user_id,
+ require_active=True,
+ )
+ if member.member_address != sender_address:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid typing sender")
+ members = await self._active_group_members(session, conversation.id)
+ for row in members:
+ server_onion = (row.member_server_onion or "").strip().lower()
+ if not server_onion or is_local_server_authority(server_onion, self.settings):
+ continue
+ remote_servers.add(server_onion)
+ return remote_servers
+
+ conversation_service.ensure_membership(conversation, sender_user_id)
+ if conversation.kind == "remote":
+ peer_onion = (conversation.peer_server_onion or "").strip().lower()
+ if peer_onion and not is_local_server_authority(peer_onion, self.settings):
+ remote_servers.add(peer_onion)
+ return remote_servers
+
+ async def _fanout_remote(
+ self,
+ session: AsyncSession,
+ *,
+ remote_servers: set[str],
+ payload: dict,
+ sender_key: str,
+ ) -> None:
+ for peer_onion in remote_servers:
+ outbox_item = await federation_outbox_service.enqueue(
+ session,
+ peer_onion=peer_onion,
+ event_type="conversation.typing",
+ endpoint_path="/api/v2/federation/conversations/typing",
+ payload_json=payload,
+ dedupe_key=f"conversation-typing:{sender_key}:{peer_onion}:{payload['relay_id']}",
+ )
+ await session.commit()
+ await federation_outbox_service.deliver_item(session, outbox_item.id)
+
+ async def publish_local_typing(
+ self,
+ session: AsyncSession,
+ *,
+ conversation: Conversation,
+ sender_user: User,
+ state: str,
+ ) -> int:
+ self._ensure_enabled()
+ normalized_state = state.strip().lower()
+ if normalized_state not in {"on", "off"}:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="state must be on or off")
+
+ sender_address = server_address_for_username(sender_user.username)
+ local_recipient_user_ids = await self._local_recipient_user_ids(
+ session,
+ conversation,
+ sender_user_id=sender_user.id,
+ sender_address=sender_address,
+ )
+ remote_servers = await self._remote_peer_servers_for_typing(
+ session,
+ conversation,
+ sender_user_id=sender_user.id,
+ sender_address=sender_address,
+ )
+
+ expires_in_ms = int(self.settings.typing_indicator_ttl_seconds * 1000)
+ event_payload = {
+ "type": "conversation.typing",
+ "conversation_id": conversation.id,
+ "from_user_address": sender_address,
+ "state": normalized_state,
+ "expires_in_ms": expires_in_ms,
+ "sent_at": datetime.now(UTC).isoformat(),
+ }
+ for user_id in local_recipient_user_ids:
+ await connection_manager.send_to_user(user_id, event_payload)
+
+ relay_payload = {
+ "relay_id": str(uuid4()),
+ "conversation_id": conversation.id,
+ "from_user_address": sender_address,
+ "state": normalized_state,
+ "expires_in_ms": expires_in_ms,
+ "sent_at": event_payload["sent_at"],
+ }
+ await self._fanout_remote(
+ session,
+ remote_servers=remote_servers,
+ payload=relay_payload,
+ sender_key=f"{conversation.id}:{sender_address}:{normalized_state}",
+ )
+ return expires_in_ms
+
+ async def relay_typing_from_federation(
+ self,
+ session: AsyncSession,
+ payload: FederationConversationTypingRelayRequestV2,
+ ) -> None:
+ self._ensure_enabled()
+ conversation = await conversation_service.get_by_id(session, payload.conversation_id)
+ if conversation is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
+
+ state = payload.state.strip().lower()
+ if state not in {"on", "off"}:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="state must be on or off")
+
+ sender_address = payload.from_user_address.strip().lower()
+ recipients: set[str] = set()
+ if conversation.conversation_type == "group":
+ members = await self._active_group_members(session, conversation.id)
+ member_addresses = {row.member_address for row in members}
+ if sender_address not in member_addresses:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Typing sender is not a group member")
+ recipients = {
+ row.member_user_id
+ for row in members
+ if row.member_user_id is not None and row.member_address != sender_address
+ }
+ elif conversation.kind == "remote":
+ if sender_address != (conversation.peer_address or "").strip().lower():
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Typing sender mismatch")
+ if conversation.local_user_id:
+ recipients.add(conversation.local_user_id)
+
+ event_payload = {
+ "type": "conversation.typing",
+ "conversation_id": conversation.id,
+ "from_user_address": sender_address,
+ "state": state,
+ "expires_in_ms": payload.expires_in_ms,
+ "sent_at": payload.sent_at,
+ }
+ for user_id in recipients:
+ await connection_manager.send_to_user(user_id, event_payload)
+
+
+typing_service_v2 = TypingServiceV2()
diff --git a/server/app/services/version_info.py b/server/app/services/version_info.py
new file mode 100644
index 0000000..db2a85d
--- /dev/null
+++ b/server/app/services/version_info.py
@@ -0,0 +1,43 @@
+from __future__ import annotations
+
+import os
+import subprocess
+from functools import lru_cache
+from pathlib import Path
+
+
+def _safe_env(name: str, fallback: str = "unknown") -> str:
+ value = os.getenv(name, "").strip()
+ return value if value else fallback
+
+
+def _repo_root() -> Path:
+ return Path(__file__).resolve().parents[3]
+
+
+def _resolve_git_commit() -> str:
+ explicit = os.getenv("BLACKWIRE_GIT_COMMIT", "").strip()
+ if explicit:
+ return explicit
+ try:
+ output = subprocess.check_output(
+ ["git", "rev-parse", "--short", "HEAD"],
+ cwd=_repo_root(),
+ stderr=subprocess.DEVNULL,
+ text=True,
+ timeout=2,
+ )
+ except Exception:
+ return "unknown"
+ commit = output.strip()
+ return commit if commit else "unknown"
+
+
+@lru_cache(maxsize=1)
+def resolve_server_version_info() -> dict[str, str]:
+ return {
+ "server_version": _safe_env("BLACKWIRE_SERVER_VERSION", "0.3.0"),
+ "api_version": "v2",
+ "git_commit": _resolve_git_commit(),
+ "build_timestamp": _safe_env("BLACKWIRE_BUILD_TIMESTAMP", "unknown"),
+ }
diff --git a/server/migrations/versions/20260301_0009_conversation_read_cursors.py b/server/migrations/versions/20260301_0009_conversation_read_cursors.py
new file mode 100644
index 0000000..9578660
--- /dev/null
+++ b/server/migrations/versions/20260301_0009_conversation_read_cursors.py
@@ -0,0 +1,83 @@
+"""conversation read cursors
+
+Revision ID: 20260301_0009
+Revises: 20260224_0008
+Create Date: 2026-03-01 14:10:00
+"""
+
+from collections.abc import Sequence
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = "20260301_0009"
+down_revision: str | None = "20260224_0008"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "conversation_read_cursors",
+ sa.Column("id", sa.String(length=36), primary_key=True),
+ sa.Column(
+ "conversation_id",
+ sa.String(length=36),
+ sa.ForeignKey("conversations.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column(
+ "user_id",
+ sa.String(length=36),
+ sa.ForeignKey("users.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column("user_address", sa.String(length=320), nullable=False),
+ sa.Column("last_read_message_id", sa.String(length=36), nullable=False),
+ sa.Column("last_read_sent_at_ms", sa.BigInteger(), nullable=False),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
+ sa.UniqueConstraint("conversation_id", "user_id", name="uq_conversation_read_cursor_user"),
+ )
+ op.create_index(
+ "ix_conversation_read_cursors_conversation_id",
+ "conversation_read_cursors",
+ ["conversation_id"],
+ unique=False,
+ )
+ op.create_index("ix_conversation_read_cursors_user_id", "conversation_read_cursors", ["user_id"], unique=False)
+ op.create_index(
+ "ix_conversation_read_cursors_user_address",
+ "conversation_read_cursors",
+ ["user_address"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_conversation_read_cursors_last_read_sent_at_ms",
+ "conversation_read_cursors",
+ ["last_read_sent_at_ms"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_conversation_read_cursors_updated_at",
+ "conversation_read_cursors",
+ ["updated_at"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_conversation_read_cursors_conversation_updated_at",
+ "conversation_read_cursors",
+ ["conversation_id", "updated_at"],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+ op.drop_index("ix_conversation_read_cursors_conversation_updated_at", table_name="conversation_read_cursors")
+ op.drop_index("ix_conversation_read_cursors_updated_at", table_name="conversation_read_cursors")
+ op.drop_index("ix_conversation_read_cursors_last_read_sent_at_ms", table_name="conversation_read_cursors")
+ op.drop_index("ix_conversation_read_cursors_user_address", table_name="conversation_read_cursors")
+ op.drop_index("ix_conversation_read_cursors_user_id", table_name="conversation_read_cursors")
+ op.drop_index("ix_conversation_read_cursors_conversation_id", table_name="conversation_read_cursors")
+ op.drop_table("conversation_read_cursors")
diff --git a/server/tests/integration/test_v2_typing_read_version.py b/server/tests/integration/test_v2_typing_read_version.py
new file mode 100644
index 0000000..617cd72
--- /dev/null
+++ b/server/tests/integration/test_v2_typing_read_version.py
@@ -0,0 +1,342 @@
+import base64
+import hashlib
+import time
+import uuid
+
+from app.services.message_service_v2 import canonical_message_signature_string
+from app.services.typing_service_v2 import typing_service_v2
+from tests.integration.test_v2_security_core import (
+ _aggregate_chain_hash,
+ _auth_header,
+ _new_device_material,
+ _register_device_v2,
+ _register_v2_user,
+)
+
+
+def _b64(data: bytes) -> str:
+ return base64.b64encode(data).decode("ascii")
+
+
+def _sha256_hex(data: bytes) -> str:
+ return hashlib.sha256(data).hexdigest()
+
+
+def _build_dm_send_payload(
+ *,
+ conversation_id: str,
+ sender_address: str,
+ sender_device_uid: str,
+ sender_sign_material: dict,
+ sender_prev_hash: str,
+ recipient_address: str,
+ recipient_device_uid: str,
+ plaintext: bytes,
+) -> dict:
+ client_message_id = str(uuid.uuid4())
+ sent_at_ms = int(time.time() * 1000)
+ ciphertext_b64 = _b64(plaintext)
+ ciphertext_hash = _sha256_hex(plaintext)
+ aad_hash = _sha256_hex(b"")
+ sender_chain_hash = _aggregate_chain_hash(
+ sender_prev_hash,
+ client_message_id,
+ sent_at_ms,
+ [f"{recipient_device_uid}:{ciphertext_hash}:{aad_hash}"],
+ )
+ canonical = canonical_message_signature_string(
+ sender_address=sender_address,
+ sender_device_uid=sender_device_uid,
+ recipient_user_address=recipient_address,
+ recipient_device_uid=recipient_device_uid,
+ client_message_id=client_message_id,
+ sent_at_ms=sent_at_ms,
+ sender_prev_hash=sender_prev_hash,
+ sender_chain_hash=sender_chain_hash,
+ ciphertext_hash=ciphertext_hash,
+ aad_hash=aad_hash,
+ )
+ signature_b64 = _b64(sender_sign_material["sign_sk"].sign(canonical).signature)
+ return {
+ "conversation_id": conversation_id,
+ "client_message_id": client_message_id,
+ "sent_at_ms": sent_at_ms,
+ "sender_prev_hash": sender_prev_hash,
+ "sender_chain_hash": sender_chain_hash,
+ "envelopes": [
+ {
+ "recipient_user_address": recipient_address,
+ "recipient_device_uid": recipient_device_uid,
+ "ciphertext_b64": ciphertext_b64,
+ "aad_b64": None,
+ "signature_b64": signature_b64,
+ "sender_device_pubkey": sender_sign_material["sign_pk_b64"],
+ }
+ ],
+ }
+
+
+def test_v2_typing_endpoint_rejects_non_member_and_fans_out(client) -> None:
+ alice = _register_v2_user(client, "alice_v2_typing")
+ bob = _register_v2_user(client, "bob_v2_typing")
+ charlie = _register_v2_user(client, "charlie_v2_typing")
+
+ alice_device = _new_device_material("alice-typing-device")
+ bob_device = _new_device_material("bob-typing-device")
+ charlie_device = _new_device_material("charlie-typing-device")
+
+ alice_tokens = _register_device_v2(client, alice["tokens"]["bootstrap_token"], alice_device)["tokens"]
+ bob_tokens = _register_device_v2(client, bob["tokens"]["bootstrap_token"], bob_device)["tokens"]
+ charlie_tokens = _register_device_v2(client, charlie["tokens"]["bootstrap_token"], charlie_device)["tokens"]
+
+ dm = client.post(
+ "/api/v2/conversations/dm",
+ headers=_auth_header(alice_tokens["access_token"]),
+ json={"peer_username": "bob_v2_typing"},
+ )
+ assert dm.status_code == 200, dm.text
+ conversation_id = dm.json()["id"]
+
+ with client.websocket_connect("/api/v2/ws", headers=_auth_header(bob_tokens["access_token"])) as bob_ws:
+ typing = client.post(
+ f"/api/v2/conversations/{conversation_id}/typing",
+ headers=_auth_header(alice_tokens["access_token"]),
+ json={"state": "on"},
+ )
+ assert typing.status_code == 200, typing.text
+ assert typing.json()["ok"] is True
+ assert int(typing.json()["expires_in_ms"]) > 0
+
+ event = bob_ws.receive_json()
+ assert event["type"] == "conversation.typing"
+ assert event["conversation_id"] == conversation_id
+ assert event["from_user_address"] == "alice_v2_typing@local.invalid"
+ assert event["state"] == "on"
+
+ non_member = client.post(
+ f"/api/v2/conversations/{conversation_id}/typing",
+ headers=_auth_header(charlie_tokens["access_token"]),
+ json={"state": "off"},
+ )
+ assert non_member.status_code == 403
+
+
+def test_v2_typing_endpoint_rejects_when_feature_disabled(client) -> None:
+ alice = _register_v2_user(client, "alice_v2_typing_off")
+ bob = _register_v2_user(client, "bob_v2_typing_off")
+ alice_device = _new_device_material("alice-typing-off-device")
+ bob_device = _new_device_material("bob-typing-off-device")
+
+ alice_tokens = _register_device_v2(client, alice["tokens"]["bootstrap_token"], alice_device)["tokens"]
+ _register_device_v2(client, bob["tokens"]["bootstrap_token"], bob_device)
+
+ dm = client.post(
+ "/api/v2/conversations/dm",
+ headers=_auth_header(alice_tokens["access_token"]),
+ json={"peer_username": "bob_v2_typing_off"},
+ )
+ assert dm.status_code == 200, dm.text
+ conversation_id = dm.json()["id"]
+
+ previous = typing_service_v2.settings.enable_typing_v03b
+ typing_service_v2.settings.enable_typing_v03b = False
+ try:
+ response = client.post(
+ f"/api/v2/conversations/{conversation_id}/typing",
+ headers=_auth_header(alice_tokens["access_token"]),
+ json={"state": "on"},
+ )
+ assert response.status_code == 404
+ finally:
+ typing_service_v2.settings.enable_typing_v03b = previous
+
+
+def test_v2_read_cursor_monotonic_and_fanout(client) -> None:
+ alice = _register_v2_user(client, "alice_v2_read")
+ bob = _register_v2_user(client, "bob_v2_read")
+ charlie = _register_v2_user(client, "charlie_v2_read")
+
+ alice_device = _new_device_material("alice-read-device")
+ bob_device = _new_device_material("bob-read-device")
+ charlie_device = _new_device_material("charlie-read-device")
+
+ alice_tokens = _register_device_v2(client, alice["tokens"]["bootstrap_token"], alice_device)["tokens"]
+ bob_tokens = _register_device_v2(client, bob["tokens"]["bootstrap_token"], bob_device)["tokens"]
+ charlie_tokens = _register_device_v2(client, charlie["tokens"]["bootstrap_token"], charlie_device)["tokens"]
+
+ dm = client.post(
+ "/api/v2/conversations/dm",
+ headers=_auth_header(alice_tokens["access_token"]),
+ json={"peer_username": "bob_v2_read"},
+ )
+ assert dm.status_code == 200, dm.text
+ conversation_id = dm.json()["id"]
+
+ dm_other = client.post(
+ "/api/v2/conversations/dm",
+ headers=_auth_header(alice_tokens["access_token"]),
+ json={"peer_username": "charlie_v2_read"},
+ )
+ assert dm_other.status_code == 200, dm_other.text
+ other_conversation_id = dm_other.json()["id"]
+
+ payload_1 = _build_dm_send_payload(
+ conversation_id=conversation_id,
+ sender_address="alice_v2_read@local.invalid",
+ sender_device_uid=alice_tokens["device_uid"],
+ sender_sign_material=alice_device,
+ sender_prev_hash="",
+ recipient_address="bob_v2_read@local.invalid",
+ recipient_device_uid=bob_tokens["device_uid"],
+ plaintext=b"first-message",
+ )
+ send_1 = client.post(
+ "/api/v2/messages/send",
+ headers=_auth_header(alice_tokens["access_token"]),
+ json=payload_1,
+ )
+ assert send_1.status_code == 200, send_1.text
+ first_message = send_1.json()["message"]
+
+ payload_2 = _build_dm_send_payload(
+ conversation_id=conversation_id,
+ sender_address="alice_v2_read@local.invalid",
+ sender_device_uid=alice_tokens["device_uid"],
+ sender_sign_material=alice_device,
+ sender_prev_hash=first_message["sender_chain_hash"],
+ recipient_address="bob_v2_read@local.invalid",
+ recipient_device_uid=bob_tokens["device_uid"],
+ plaintext=b"second-message",
+ )
+ send_2 = client.post(
+ "/api/v2/messages/send",
+ headers=_auth_header(alice_tokens["access_token"]),
+ json=payload_2,
+ )
+ assert send_2.status_code == 200, send_2.text
+ second_message = send_2.json()["message"]
+
+ with client.websocket_connect("/api/v2/ws", headers=_auth_header(alice_tokens["access_token"])) as alice_ws:
+ read_second = client.post(
+ f"/api/v2/conversations/{conversation_id}/read",
+ headers=_auth_header(bob_tokens["access_token"]),
+ json={
+ "last_read_message_id": second_message["id"],
+ "last_read_sent_at_ms": second_message["sent_at_ms"],
+ },
+ )
+ assert read_second.status_code == 200, read_second.text
+ assert read_second.json()["last_read_message_id"] == second_message["id"]
+
+ event = alice_ws.receive_json()
+ assert event["type"] == "conversation.read"
+ assert event["conversation_id"] == conversation_id
+ assert event["reader_user_address"] == "bob_v2_read@local.invalid"
+ assert event["last_read_message_id"] == second_message["id"]
+ assert int(event["last_read_sent_at_ms"]) == int(second_message["sent_at_ms"])
+
+ stale = client.post(
+ f"/api/v2/conversations/{conversation_id}/read",
+ headers=_auth_header(bob_tokens["access_token"]),
+ json={
+ "last_read_message_id": first_message["id"],
+ "last_read_sent_at_ms": first_message["sent_at_ms"],
+ },
+ )
+ assert stale.status_code == 200, stale.text
+ assert stale.json()["last_read_message_id"] == second_message["id"]
+ assert int(stale.json()["last_read_sent_at_ms"]) == int(second_message["sent_at_ms"])
+
+ idempotent = client.post(
+ f"/api/v2/conversations/{conversation_id}/read",
+ headers=_auth_header(bob_tokens["access_token"]),
+ json={
+ "last_read_message_id": second_message["id"],
+ "last_read_sent_at_ms": second_message["sent_at_ms"],
+ },
+ )
+ assert idempotent.status_code == 200, idempotent.text
+ assert idempotent.json()["last_read_message_id"] == second_message["id"]
+
+ mismatched = client.post(
+ f"/api/v2/conversations/{other_conversation_id}/read",
+ headers=_auth_header(charlie_tokens["access_token"]),
+ json={
+ "last_read_message_id": second_message["id"],
+ "last_read_sent_at_ms": second_message["sent_at_ms"],
+ },
+ )
+ assert mismatched.status_code == 400
+
+ get_read = client.get(
+ f"/api/v2/conversations/{conversation_id}/read",
+ headers=_auth_header(bob_tokens["access_token"]),
+ )
+ assert get_read.status_code == 200, get_read.text
+ payload = get_read.json()
+ assert payload["conversation_id"] == conversation_id
+ assert len(payload["cursors"]) == 1
+ assert payload["cursors"][0]["user_address"] == "bob_v2_read@local.invalid"
+ assert payload["cursors"][0]["last_read_message_id"] == second_message["id"]
+
+
+def test_v2_federation_typing_and_read_relays_call_services(client, monkeypatch) -> None:
+ calls: list[tuple[str, str]] = []
+
+ async def _fake_verify(request, session): # noqa: ANN001
+ return await request.body()
+
+ async def _fake_relay_typing(session, payload): # noqa: ANN001
+ calls.append(("typing", payload.conversation_id))
+
+ async def _fake_relay_read(session, payload): # noqa: ANN001
+ calls.append(("read", payload.conversation_id))
+
+ monkeypatch.setattr("app.api_v2.federation._verify_federation_write_auth", _fake_verify)
+ monkeypatch.setattr("app.api_v2.federation.typing_service_v2.relay_typing_from_federation", _fake_relay_typing)
+ monkeypatch.setattr("app.api_v2.federation.read_state_service_v2.relay_read_from_federation", _fake_relay_read)
+
+ typing_response = client.post(
+ "/api/v2/federation/conversations/typing",
+ json={
+ "relay_id": str(uuid.uuid4()),
+ "conversation_id": "00000000-0000-0000-0000-000000000123",
+ "from_user_address": "alice@example.onion",
+ "state": "on",
+ "expires_in_ms": 6000,
+ "sent_at": "2026-03-01T12:00:00+00:00",
+ },
+ )
+ assert typing_response.status_code == 200, typing_response.text
+
+ read_response = client.post(
+ "/api/v2/federation/conversations/read",
+ json={
+ "relay_id": str(uuid.uuid4()),
+ "conversation_id": "00000000-0000-0000-0000-000000000123",
+ "reader_user_address": "alice@example.onion",
+ "last_read_message_id": "00000000-0000-0000-0000-000000000456",
+ "last_read_sent_at_ms": 1710000000000,
+ "updated_at": "2026-03-01T12:00:01+00:00",
+ },
+ )
+ assert read_response.status_code == 200, read_response.text
+ assert ("typing", "00000000-0000-0000-0000-000000000123") in calls
+ assert ("read", "00000000-0000-0000-0000-000000000123") in calls
+
+
+def test_v2_system_version_endpoint(client) -> None:
+ user = _register_v2_user(client, "version_v2_user")
+ material = _new_device_material("version-device")
+ tokens = _register_device_v2(client, user["tokens"]["bootstrap_token"], material)["tokens"]
+
+ response = client.get(
+ "/api/v2/system/version",
+ headers=_auth_header(tokens["access_token"]),
+ )
+ assert response.status_code == 200, response.text
+ payload = response.json()
+ assert payload["api_version"] == "v2"
+ assert isinstance(payload["server_version"], str) and payload["server_version"] != ""
+ assert isinstance(payload["git_commit"], str) and payload["git_commit"] != ""
+ assert isinstance(payload["build_timestamp"], str) and payload["build_timestamp"] != ""
diff --git a/spec/api.md b/spec/api.md
index 9c7da92..8e36e9c 100644
--- a/spec/api.md
+++ b/spec/api.md
@@ -1,141 +1,231 @@
-# Blackwire v0.1 API Spec
+# Blackwire API Spec (`v2` Primary, `v1` Compatible)
-Base URL path prefix: `/api/v1`
+## Versioning
-## Authentication
+- Primary client integration surface: `/api/v2`
+- Legacy compatibility surface: `/api/v1` (kept operational in `v0.3` wave 1)
-### `POST /auth/register`
-Registers a local user and returns auth tokens.
-`user` payload includes:
-- `id`
-- `username`
-- `created_at`
-- `user_address` (canonical `username@onion`)
-- `home_server_onion`
+---
-### `POST /auth/login`
-Authenticates and issues access/refresh tokens.
-Returns the same `user` fields listed above.
+## `/api/v2` Core
-### `POST /auth/refresh`
-Rotates refresh token and returns a new pair.
-Returns the same `user` fields listed above.
+### Authentication and Identity
-### `POST /auth/logout`
-Revokes a refresh token.
+- `POST /api/v2/auth/register`
+- `POST /api/v2/auth/login`
+- `POST /api/v2/auth/refresh`
+- `POST /api/v2/auth/logout`
+- `POST /api/v2/auth/bind-device`
+- `GET /api/v2/me`
-## Identity and Devices
+### Devices and Keys
-### `GET /me`
-Returns current authenticated user.
-Response includes:
-- `id`
-- `username`
-- `created_at`
-- `user_address`
-- `home_server_onion`
+- `POST /api/v2/devices/register`
+- `GET /api/v2/devices`
+- `POST /api/v2/devices/{device_uid}/revoke`
+- `GET /api/v2/users/resolve-devices?peer_address=...`
+- `GET /api/v2/users/resolve-prekeys?peer_address=...`
+- `POST /api/v2/keys/prekeys/upload`
-### `POST /devices/register`
-Registers a new device and marks it active.
+### Presence
-### `GET /users/{username}/device`
-Legacy local-only device lookup.
+- `POST /api/v2/presence/set`
+- `POST /api/v2/presence/resolve`
-### `GET /users/resolve-device?peer_address=username@onion`
-Resolves local or federated active recipient device for a canonical peer address.
-Client always calls this on its home server; remote lookup is server-to-server federation.
+### Conversations and Messages
-## Conversations
+- `POST /api/v2/conversations/dm`
+- `POST /api/v2/conversations/group`
+- `GET /api/v2/conversations`
+- `GET /api/v2/conversations/{conversation_id}/members`
+- `POST /api/v2/conversations/{conversation_id}/members/invite`
+- `POST /api/v2/conversations/{conversation_id}/members/{member_address}/remove`
+- `POST /api/v2/conversations/{conversation_id}/invites/accept`
+- `POST /api/v2/conversations/{conversation_id}/leave`
+- `POST /api/v2/conversations/{conversation_id}/rename`
+- `GET /api/v2/conversations/{conversation_id}/recipients`
+- `GET /api/v2/conversations/{conversation_id}/messages`
+- `POST /api/v2/messages/send`
-### `POST /conversations/dm`
-Creates/returns a DM conversation.
+---
-Request supports:
-- `peer_address` (`username@onion`) for canonical local/remote routing.
-- `peer_username` as local-only backward-compatible alias.
+## `v0.3b` Additions (`/api/v2`)
-Response includes:
-- `id`
-- `kind` (`local` or `remote`)
-- `user_a_id`, `user_b_id` (local conversations)
-- `local_user_id` (remote conversations)
-- `created_at`
-- `peer_username`
-- `peer_server_onion`
-- `peer_address`
+### Typing Indicator Write Contract
-### `GET /conversations`
-Lists local and federated conversations with the same fields above.
+`POST /api/v2/conversations/{conversation_id}/typing`
-### `GET /conversations/{conversation_id}/messages`
-Lists stored ciphertext envelopes for a conversation.
+Request:
-## Messaging
+```json
+{
+ "state": "on",
+ "client_ts_ms": 1700000000000
+}
+```
-### `POST /messages/send`
-Sends a ciphertext envelope to the target device.
+Response:
-Response message fields include:
-- `sender_user_id` (nullable for federated relayed messages)
-- `sender_address` (canonical sender identity)
-- `sender_device_id`
-- `envelope_json`
+```json
+{
+ "ok": true,
+ "expires_in_ms": 6000
+}
+```
-## WebSocket
+Rules:
-### `GET /ws`
-Authenticated websocket for message delivery and voice signaling.
-Requires `Authorization: Bearer ` during websocket handshake.
+1. `state` is `on|off`.
+2. Typing is ephemeral and not persisted.
+3. Sender is excluded from fanout.
-Server `message.new` payload includes:
-- `message.sender_user_id`
-- `message.sender_address`
+### Read Cursor Write and Query Contracts
-Server call events keep existing names and add address fields:
-- `call.incoming.from_user_address`
-- `call.ringing.peer_user_address`
-- `call.accepted.peer_user_address`
-- `call.audio.from_user_address`
+`POST /api/v2/conversations/{conversation_id}/read`
+
+Request:
+
+```json
+{
+ "last_read_message_id": "message-id",
+ "last_read_sent_at_ms": 1700000000123
+}
+```
+
+Response:
+
+```json
+{
+ "conversation_id": "conversation-id",
+ "reader_user_address": "alice@local.invalid",
+ "last_read_message_id": "message-id",
+ "last_read_sent_at_ms": 1700000000123,
+ "updated_at": "2026-03-01T12:00:00.000000+00:00"
+}
+```
+
+`GET /api/v2/conversations/{conversation_id}/read`
+
+Response:
+
+```json
+{
+ "conversation_id": "conversation-id",
+ "cursors": [
+ {
+ "user_address": "alice@local.invalid",
+ "last_read_message_id": "message-id",
+ "last_read_sent_at_ms": 1700000000123,
+ "updated_at": "2026-03-01T12:00:00.000000+00:00"
+ }
+ ]
+}
+```
+
+Rules:
+
+1. Cursor is persisted per `(conversation_id, user_id)`.
+2. Updates are monotonic; stale regressions are treated as no-op.
+
+### System Version Contract
+
+`GET /api/v2/system/version`
+
+Response:
+
+```json
+{
+ "server_version": "0.3.0",
+ "api_version": "v2",
+ "git_commit": "abc1234",
+ "build_timestamp": "2026-03-01T00:00:00Z"
+}
+```
+
+---
+
+## `/api/v2/ws` Events
+
+### Client-to-server events
-Client events remain:
- `message.ack`
- `call.offer`
- `call.accept`
- `call.reject`
- `call.end`
-- `call.audio`
+- `call.audio` (legacy WS audio mode when enabled)
+- `call.webrtc.offer`
+- `call.webrtc.answer`
+- `call.webrtc.ice`
+
+Typing and read writes are REST-only (`POST /typing`, `POST /read`), not websocket writes.
+
+### Server-to-client events
+
+- `message.new`
+- `conversation.group.renamed`
+- `conversation.typing`
+- `conversation.read`
+- Voice and group call events (`call.*`, `call.group.*`)
+
+`conversation.typing` payload:
+
+```json
+{
+ "type": "conversation.typing",
+ "conversation_id": "conversation-id",
+ "from_user_address": "bob@local.invalid",
+ "state": "on",
+ "expires_in_ms": 6000,
+ "sent_at": "2026-03-01T12:00:00.000000+00:00"
+}
+```
-## Federation (`/federation`)
+`conversation.read` payload:
-### `GET /federation/well-known`
-Returns:
-- `server_onion`
-- `federation_version`
-- `signing_public_key`
+```json
+{
+ "type": "conversation.read",
+ "conversation_id": "conversation-id",
+ "reader_user_address": "bob@local.invalid",
+ "last_read_message_id": "message-id",
+ "last_read_sent_at_ms": 1700000000123,
+ "updated_at": "2026-03-01T12:00:00.000000+00:00"
+}
+```
-### `GET /federation/users/{username}/device`
-Returns local user active device and canonical `peer_address` for remote servers.
+---
+
+## `/api/v2/federation`
+
+### Well-known and lookup
+
+- `GET /api/v2/federation/well-known`
+- `GET /api/v2/federation/users/{username}/devices`
+- `GET /api/v2/federation/users/{username}/prekeys`
+- `GET /api/v2/federation/groups/{group_uid}/snapshot`
+
+### Signed federation write endpoints
-### Signed Federation Write Endpoints
Required headers:
+
- `X-BW-Fed-Server`
- `X-BW-Fed-Timestamp`
- `X-BW-Fed-Nonce`
- `X-BW-Fed-Signature`
-Canonical signature string:
-`METHOD\nPATH\nSHA256(BODY)\nTIMESTAMP\nNONCE\nSENDER_ONION`
+Write endpoints include:
+
+- `POST /api/v2/federation/messages/relay`
+- `POST /api/v2/federation/groups/events`
+- `POST /api/v2/federation/groups/invites/accept`
+- `POST /api/v2/federation/conversations/typing`
+- `POST /api/v2/federation/conversations/read`
+- group-call and call signaling relay routes (`/group-calls/*`, `/calls/webrtc-*`)
-Write/control endpoints:
-- `POST /federation/messages/relay`
-- `POST /federation/calls/offer`
-- `POST /federation/calls/accept`
-- `POST /federation/calls/reject`
-- `POST /federation/calls/end`
-- `POST /federation/calls/audio`
+---
-## Health and Metrics
+## `/api/v1` Compatibility Notes
-- `GET /health/live`
-- `GET /health/ready`
-- `GET /api/v1/metrics` (requires bearer auth)
+`/api/v1` remains functional in this wave for compatibility with older clients.
+It retains the existing auth/device/conversation/message/ws/federation routes and semantics while `/api/v2` is the primary integration surface.
From 475a3ec43d4cd30abe356549f914b25048ef7f4a Mon Sep 17 00:00:00 2001
From: wdani <68472201+WilleLX1@users.noreply.github.com>
Date: Wed, 4 Mar 2026 16:23:55 +0100
Subject: [PATCH 3/3] feat: update index.html for v0.3 release with new
features and descriptions
---
index.html | 139 ++++++++++++++++++++++++-----------------------------
1 file changed, 64 insertions(+), 75 deletions(-)
diff --git a/index.html b/index.html
index 1ba8276..4197a6c 100644
--- a/index.html
+++ b/index.html
@@ -3,10 +3,10 @@
- Blackwire v0.2 | Security-First Federated Messaging
+ Blackwire v0.3 | Security-First Federated Messaging
@@ -422,14 +422,14 @@
-
v0.2 Release
-
Device-bound trust and ratchet-ready federated messaging.
+
v0.3 Release
+
Introducing Blackwire v0.3
- Blackwire v0.2 ships signed per-device envelopes, true multi-device accounts with revoke,
- asymmetric device-bound JWT sessions, integrity chains, and staged ratchet/WebRTC rollout.
+ Blackwire v0.3 delivers real-time group messaging with end-to-end encryption, group conversations, typing indicators, read cursors, markdown rendering
+ with inline media, encrypted message cache with privacy control, and federated voice calls.
@@ -438,74 +438,75 @@ Device-bound trust and ratchet-ready federated messaging.
$ docker compose -f infra/docker-compose.yml up --build
-$env:BLACKWIRE_ENABLE_RATCHET_V2B1 = "true"
-$env:BLACKWIRE_ENABLE_WEBRTC_V2B2 = "true"
+# Qt GUI client with group DM/calls, typing, read cursors
+$ cd client-cpp-gui && ./scripts/run.ps1 -Config Release
-v2 auth: /api/v2/auth/login -> /api/v2/auth/bind-device
-v2 send: /api/v2/messages/send (sealedbox_v0_2a | ratchet_v0_2b1)
-v2 federation: /api/v2/federation/well-known
-v2 websocket: /api/v2/ws (Bearer auth)
+v0.3 group: /api/v2/conversations/group
+v0.3 typing: POST /api/v2/conversations/{id}/typing
+v0.3 read: POST /api/v2/conversations/{id}/read
+v0.3 system: GET /api/v2/system/version
-
v0.2 Highlights
+
v0.3 Wave 1 Features
- v0.2a security milestones are complete, with v0.2b ratchet and WebRTC paths delivered behind rollout gates.
+ Group conversations, typing indicators, read cursors, markdown rendering, encrypted message cache,
+ and enhanced client UX with federated voice calls over WebRTC.
- Client-Signed Envelopes
- Outbound messages are signed per device, and receivers verify signatures before accept/decrypt.
+ Group Conversations
+ Create and manage group DMs with member management, typing indicators, and read receipts via /api/v2.
- Sender Key Pinning
- TOFU pinning by sender_device_uid raises integrity warnings on signing-key changes.
+ Typing Indicators & Read Cursors
+ Real-time typing state and persistent conversation-level read cursors with federation relay support.
- Multi-Device + Revoke
- Accounts now support multiple active devices with immediate revoke/kick enforcement.
+ Markdown + Inline Media
+ CommonMark-compatible message rendering with raw HTML disabled. Inline images and click-to-play video dialogs.
- Device-Bound JWT Sessions
- Bootstrap-to-bind flow issues asymmetric tokens with device identity claims validated on every request.
+ Encrypted Message Cache
+ Optional local message cache with user privacy toggle: messages persist encrypted or cleared on restart.
- Integrity Chain Detection
- Signed chain fields detect gaps/reordering/tampering and surface explicit integrity warnings in UI.
+ Attachment Lifecycle UX
+ File send states (queued, sending, success, failed) with retry action for failed attachments in chat UI.
- Ratchet + WebRTC Rollout
- Prekey/X3DH + Double Ratchet and WebRTC signaling/media are wired through v2 dual-stack contracts.
+ Version Visibility & Link Safety
+ Client/server version display in settings. External link confirmation dialogs prevent phishing clicks.
-
v0.2 Rollout Model
+
v0.3 Architecture
- Migration is incremental: v2 features run in parallel with v1 compatibility while ratchet and WebRTC move to default.
+ Federated group messaging with typing/read sync, encrypted local storage, and WebRTC voice on top of core v2 security.
- Parallel API Surface
- /api/v2 is implemented without removing /api/v1, allowing staged client migration.
+ Group Delivery
+ Server-side fanning to group members with federation relay for remote members.
- Mode Negotiation
- encryption_mode supports sealed-box fallback and ratchet mode with explicit policy gates.
+ Ephemeral Typing State
+ Typing events expire by TTL, are sender-excluded, and fan out via WebSocket and federation.
- Federation Trust Binding
- v2 federation well-known metadata binds trust to Tor onion identity while preserving signed relay controls.
+ Persistent Read Cursors
+ Monotonic per-conversation cursors with stale-update rejection and federation sync.
- Voice Transport Migration
- WebRTC signaling and media are added in v2 while legacy WS PCM can be disabled after stabilization.
+ Client-Local Privacy
+ Encrypted message cache on disk with AES-GCM, optional default-on, user can disable for privacy.
@@ -518,33 +519,20 @@ API Surface (/api/v2)
- POST /api/v2/auth/register
- POST /api/v2/auth/login
- POST /api/v2/auth/bind-device
- POST /api/v2/auth/refresh
- POST /api/v2/auth/logout
- POST /api/v2/devices/register
- GET /api/v2/devices
- POST /api/v2/devices/{device_uid}/revoke
- GET /api/v2/me
- GET /api/v2/users/resolve-devices?peer_address=...
- GET /api/v2/users/resolve-prekeys?peer_address=...
- POST /api/v2/keys/prekeys/upload
- POST /api/v2/messages/send
- GET /api/v2/conversations
- POST /api/v2/conversations/dm
- GET /api/v2/conversations/{conversation_id}/messages
- GET /api/v2/federation/well-known
- GET /api/v2/federation/users/{username}/devices
- GET /api/v2/federation/users/{username}/prekeys
- POST /api/v2/federation/messages/relay
- POST /api/v2/federation/calls/webrtc-offer
- POST /api/v2/federation/calls/webrtc-answer
- POST /api/v2/federation/calls/webrtc-ice
- POST /api/v2/federation/group-calls/webrtc-offer
- POST /api/v2/federation/group-calls/webrtc-answer
- POST /api/v2/federation/group-calls/webrtc-ice
- WS /api/v2/ws
+ Auth: POST /api/v2/auth/register|login|refresh|logout
+ Devices: GET|POST /api/v2/devices, POST .../revoke
+ Keys: POST /api/v2/keys/prekeys/upload
+ Messaging: POST /api/v2/messages/send
+ Conversations: GET /api/v2/conversations, POST .../dm, POST .../group
+ Typing: POST /api/v2/conversations/{id}/typing
+ Read Cursor: POST|GET /api/v2/conversations/{id}/read
+ System: GET /api/v2/system/version
+ Presence: POST /api/v2/presence/set|resolve
+ Federation typing: POST /api/v2/federation/conversations/typing
+ Federation read: POST /api/v2/federation/conversations/read
+ Federation relay: POST /api/v2/federation/messages/relay
+ Voice calls: WebRTC via federation /api/v2/federation/calls/*
+ WebSocket: WS /api/v2/ws (events: typing, read, messages)
@@ -562,30 +550,31 @@ Prepare Environment
./infra/randomize-env-secrets.ps1
- Enable v0.2b Flags
- BLACKWIRE_ENABLE_RATCHET_V2B1=true
- BLACKWIRE_ENABLE_WEBRTC_V2B2=true
+ Start Server Stack
+ docker compose -f infra/docker-compose.yml up --build
+ http://localhost:8000/docs
- Set Voice Mode
- BLACKWIRE_ENABLE_LEGACY_CALL_AUDIO_WS=false
- BLACKWIRE_WEBRTC_ICE_SERVERS_JSON=[{"urls":"stun:stun.l.google.com:19302"}]
+ Launch Qt Client
+ cd client-cpp-gui && ./scripts/bootstrap-windows.ps1
+ ./scripts/run.ps1 -Config Release
- Start + Verify v2
- docker compose -f infra/docker-compose.yml up --build
- http://localhost:8000/api/v2/federation/well-known
+ Test v0.3 Features
+ Create group DM, test typing/read indicators
+ Try message cache toggle in Settings → Data & Privacy
- v0.2a security milestones are implemented; v0.2b ratchet/WebRTC paths are available with feature gates while /api/v1 remains compatible.
+ v0.3 Wave 1 is fully implemented: group messaging, typing/read cursors, markdown rendering,
+ encrypted message cache with privacy control, and WebRTC federated voice calls. /api/v1 remains compatible.