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.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..237a20a --- /dev/null +++ b/V0.3_Update.md @@ -0,0 +1,175 @@ +# Blackwire v0.3 Update (Wave 1 Delivered: `0.3a + 0.3b`) + +## Scope + +Wave 1 delivers: + +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. + +`0.3c` stabilization and deprecation hardening remains a follow-up wave. + +## Locked Decisions Applied + +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 Changes + +### Defaults and Flags + +- `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` + +### New API Contracts (`/api/v2`) + +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":"..."}` + +### New Federation Relay Endpoints (`/api/v2/federation`) + +1. `POST /api/v2/federation/conversations/typing` +2. `POST /api/v2/federation/conversations/read` + +Both use existing signed federation write authentication. + +### New WS Event Fanout + +1. `conversation.typing` +2. `conversation.read` + +Client-sent typing/read over websocket remains unsupported by design; writes are REST-only and fanout is websocket. + +### Data Model and Migration + +- New table: `conversation_read_cursors` +- Unique key: `(conversation_id, user_id)` +- Indexed for fast conversation cursor reads and recency ordering. + +### Behavior Rules Implemented + +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. + +## Client Changes + +### Messaging and Rendering + +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. + +### Realtime UX + +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. + +### Attachment Send Lifecycle + +Outgoing attachment states in local UX: + +1. `queued` +2. `sending` +3. `success` +4. `failed` +5. retry action transitions failed attachment back to sending. + +### Settings: My Account + +`Settings -> My Account` now shows: + +1. Client version +2. Server version + +Version display is informational and does not gate auth/session flows. + +## Validation Status + +### Server tests + +- New integration suite for typing/read/version: passing. +- Existing v2 group/security suites: passing. +- Multiple legacy `/api/v1` and compatibility integration suites: passing. + +### Client tests + +Added/extended unit coverage for: + +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 + +Note: GUI build/test execution requires local CMake/Qt toolchain availability in the environment. + +## Post-Wave 1 Enhancements (This Session) + +### UI & UX Polish + +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) + +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 + +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 + +### Bug Fixes + +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 + +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 + +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 + +### Data Integrity Fixes + +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/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.
-
Blackwire v0.2 release showcase site for GitHub Pages.
+
Blackwire v0.3 Wave 1 release showcase site for GitHub Pages.
Updated | See README.md for full setup details.
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.