diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c877ede..e11e768 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -33,9 +33,10 @@ Marchat is a self-hosted, terminal-based chat application built in Go with a cli The client is a standalone terminal user interface built with the Bubble Tea framework. It's a complete application that can be built and run independently. The code is split across several files: - **`main.go`**: Core model, state, Update loop, and command handlers +- **`chrome.go`**: Status footer builder (`buildStatusFooter`), debounced tail `read_receipt` sends, and `stripKindForBanner` plus `BannerStrip` styles so `[ERROR]` / `[WARN]` / other banner lines use distinct strip colors (built-in themes and optional `banner_*` keys in custom `themes.json`) - **`cli_output.go`**: Lipgloss helpers for pre-TUI stdout (connection, E2E status, profile flow messages) - **`hotkeys.go`**: Key binding definitions and methods -- **`render.go`**: Message rendering and UI display logic (optional per-line metadata: message id and encrypted flag) +- **`render.go`**: Message rendering and UI display logic (optional per-line metadata: message id and encrypted flag); `System` transcript lines use `systemLineStyle` so errors and warnings are not forced into the same color as timestamps - **`websocket.go`**: WebSocket connection management, send/receive, and E2E encryption helpers - **`exthook/`**: Optional, experimental subprocess hooks for local automation (stdin JSON per event); see **CLIENT_HOOKS.md** - **`commands.go`**: Help text generation and command-related utilities @@ -69,7 +70,10 @@ The client is a standalone terminal user interface built with the Bubble Tea fra - URL detection and external opening - Tab completion for @mentions - Connection status indicator -- Unread message count +- Unread count in the footer: increments only for other users' new `text`, `dm`, or `file` lines while the transcript viewport is scrolled up (not for typing, reactions, read receipts, edits, deletes, or your own echoed sends) +- Optional debounced `read_receipt` to the server when the viewport follows the newest messages; failures surface in the banner only +- Footer shows `E2E` when encryption is on, and `#channel` when the current room is not `general`; plaintext sessions omit an explicit `Unencrypted` label in the footer +- Automatic WebSocket reconnect with exponential backoff (capped); on each successful connect (`wsConnected`), the reference client clears the in-memory transcript and related UI state before processing server history replay, so a server restart or network drop does not duplicate messages that were already on screen - Multi-line input via Alt+Enter / Ctrl+J - **Diagnostics**: `-doctor` and `-doctor-json` for environment, paths, and config checks (`internal/doctor`) @@ -80,7 +84,7 @@ The server is a standalone HTTP/WebSocket server application that provides real- #### Core Structures - **`Hub`**: Central message routing system managing client connections, message broadcasting, channel management, and user state; tracks reserved usernames so handshake cannot double-book the same name before a client is registered. All sends to `client.send` use non-blocking `select/default` to prevent deadlocks when a client's write buffer is full; stalled clients are dropped or the message is logged and skipped. **Text** messages fan out to plugins in a **separate goroutine** so plugin IPC never blocks the hub’s broadcast loop. -- **`Client`**: Individual WebSocket connection handler with read/write pumps and command processing. The `writePump` goroutine is started **before** history replay on connect so the send channel always has a consumer. +- **`Client`**: Individual WebSocket connection handler with read/write pumps and command processing. The `writePump` goroutine is started **before** history replay on connect so the send channel always has a consumer. `handleCommand` sends a `System` `text` reply to admins when a `:` command is not handled by plugins or built-ins (unknown token), so clients always get a wire response for that case. - **`AdminPanel`**: Terminal-based administrative interface for server management - **`WebAdminServer`**: Web-based administrative interface with session authentication - **`HealthChecker`**: System health monitoring with metrics collection @@ -100,7 +104,7 @@ The server is a standalone HTTP/WebSocket server application that provides real- - System metrics collection and health monitoring - Web-based admin panel with CSRF protection - Health check endpoints for monitoring systems -- WebSocket message rate limiting +- WebSocket per-connection message rate limiting; when the configured burst is exceeded the server sends one `System` `text` notice to that client, then ignores inbound JSON until cooldown (see **PROTOCOL.md**) - **Diagnostics**: `-doctor` and `-doctor-json` without binding ports (`internal/doctor`) ### Server Library (`server/`) diff --git a/PROTOCOL.md b/PROTOCOL.md index d6a206c..759ed27 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -130,7 +130,7 @@ These values of `type` extend the core chat protocol: | `dm` | Direct message. Requires `recipient` (target username). Delivered only to sender and recipient. | | `search` | Full-text search. `content` is the search query; the server replies with a private `text` message from `System` listing up to 20 matches (not broadcast). | | `pin` | Toggle pinned state for `message_id`. **Admin only**; non-admins receive an error `text` from `System`. On success, a `System` `text` notice is broadcast. | -| `read_receipt` | Read-receipt notification. Relayed to connected clients; payload conventions (e.g. which message was read) may use `content` and/or `message_id` as agreed by clients. | +| `read_receipt` | Read receipt. Clients may send `read_receipt` with `message_id` set to the latest persisted chat line they have read while the transcript viewport is scrolled to the tail (the reference client debounces bursts). The server sets `sender` from the connection, persists when `message_id` is positive (`read_receipts` table), and broadcasts the message. Receivers may ignore or surface receipts in UI; the reference TUI does not print them in the transcript. | | `join_channel` | Join a channel. Requires `channel` (name). If the client was in another channel, they leave it first. The server sends a confirmation `text` from `System`. | | `leave_channel` | Leave the current channel and return to `#general`. No `content` required. If already in `general`, no-op. The server sends a confirmation `text` from `System`. | @@ -168,7 +168,7 @@ The server stores and relays opaque `content` (and encrypted file blobs) without ## Server Behavior - On successful handshake: - - Sends up to 50 recent messages from history (newest first; clients typically display in chronological order). + - Sends up to 50 recent messages from history (newest first; clients typically display in chronological order). The same replay happens on every new WebSocket session after a disconnect. Clients that keep a local transcript in memory should **replace** or **dedupe** against that replay instead of appending it blindly, or they will show duplicate lines. The reference TUI clears its transcript (messages, reactions, typing state, and cached received-file metadata) when a connection succeeds so the replay is the single source of truth for the visible scrollback window. - Sends current user list. - On user connect/disconnect: - Broadcasts updated user list. @@ -178,6 +178,7 @@ The server stores and relays opaque `content` (and encrypted file blobs) without - Reactions, read receipts, and last channel per user may be persisted server-side and replayed to reconnecting clients. - Message history is capped at 1000 messages. - On successful `type`: `edit`, the server updates `content`, sets `edited`, and sets stored encryption metadata from the incoming `encrypted` field (so edited ciphertext rows remain ciphertext with `is_encrypted` aligned to the wire). +- `:`-prefixed input and `admin_command` messages go through the server command path (plugins first, then built-in admin commands for admins). Non-admins may receive a `System` `text` line when a command requires admin privileges. Admins receive a `System` `text` line when no plugin handler and no built-in handler matches the first token (content includes `Unknown command:` and that token). --- @@ -197,7 +198,7 @@ Per WebSocket connection, the server enforces rate limiting on **all** incoming - **Burst:** at most **20 messages** per **5 second** sliding window. - **Cooldown:** if the limit is exceeded, further incoming messages from that connection are ignored until **10 seconds** have elapsed since the violation, then counting resumes. -Exceeded messages are dropped silently from the client’s perspective (the server logs the event). Alternative clients should pace high-frequency traffic accordingly. +When the burst threshold is crossed, the server sends one `System` `text` notice to that connection, then ignores further incoming JSON until the cooldown elapses (the server still logs drops). Messages received during the cooldown window are dropped without an extra notice. Alternative clients should pace high-frequency traffic accordingly. --- diff --git a/README.md b/README.md index 8cb8823..56776bc 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ Screen recordings of a current build (GIF autoplay depends on the viewer). - **Direct Messages** - Private DM conversations between users - **Channels** - Multiple chat rooms with join/leave and per-channel messaging - **Typing Indicators** - See when other users are typing -- **Read Receipts** - Message read acknowledgement (broadcast-level) +- **Read Receipts** - Server stores per-user read rows; the reference client sends debounced `read_receipt` when the transcript is scrolled to the newest messages - **Plugin System** - Remote registry with text commands and Alt+key hotkeys - **E2E Encryption** - ChaCha20-Poly1305 with a shared global key (`MARCHAT_GLOBAL_E2E_KEY`), including file transfers - **File Sharing** - Send files up to 1MB (configurable) with interactive picker and optional E2E encryption @@ -87,7 +87,7 @@ Screen recordings of a current build (GIF autoplay depends on the viewer). - **Docker Support** - Containerized deployment with `docker-compose.yml` for local dev; optional **TLS reverse proxy** via Caddy ([guide](deploy/CADDY-REVERSE-PROXY.md)) - **Health Monitoring** - `/health` and `/health/simple` endpoints with system metrics - **Structured Logging** - JSON logs with component separation and user tracking -- **UX Enhancements** - Connection status indicator, tab completion for @mentions, unread message count, multi-line input, chat export +- **UX Enhancements** - Stable status footer (connection, unread for others' new chat lines when scrolled above the tail, optional E2E and channel), banner for command feedback, tab completion for @mentions, multi-line input, chat export - **Cross-Platform** - Runs on Linux, macOS, Windows, and Android/Termux - **Diagnostics** - `marchat-client -doctor` and `marchat-server -doctor` (or `-doctor-json`) summarize environment, resolved paths, and configuration health @@ -765,6 +765,9 @@ The TUI client can spawn **optional** external programs on send/receive and pass | MySQL connection fails | Verify DSN prefix `mysql:` and body `user:pass@tcp(host:3306)/db?parseTime=true`; test with the `mysql` CLI. | | SQL syntax error after backend switch | Ensure tables were created by the current server version and restart after changing `MARCHAT_DB_PATH`. | | Message history looks incomplete | History depends on **channel**, **per-user message state**, and server filters. **Ban/unban** and related flows can reset stored state so scrollback differs from the raw DB. | +| Transcript resets after reconnect | On each successful WebSocket connect the reference client clears local messages and rebuilds from the server handshake replay (up to 50 recent lines). That avoids duplicates when the server comes back while the client stayed open. Lines older than that replay window are not shown again in that session unless you saved them with **`:export`** earlier. Third-party clients should replace or dedupe history on handshake; see **PROTOCOL.md** (Server Behavior). | +| Banner stuck on `[Sending...]` | Current **client** clears sending after each successful WebSocket write for normal chat and for `admin_command`, so dropped or slow server work does not leave the banner stuck. Current **server** sends a `System` line for unknown admin `:` commands (`Unknown command:` plus the token) and one `System` line when per-connection message rate burst is exceeded (see **PROTOCOL.md** Rate Limiting). | +| Footer unread looks wrong | The reference client increments unread only for other users' new `text`, `dm`, or `file` while the transcript is not at the bottom. It does not increment for typing, reactions, read receipts, edits, deletes, or your own echoed sends. See **ARCHITECTURE.md** (client). | | Ban history gaps not working | Set `MARCHAT_BAN_HISTORY_GAPS=true` (default off). The server creates the **`ban_history`** table when using a database backend that runs marchat migrations. | | TLS certificate errors | For dev/self-signed certs, pass **`--skip-tls-verify`** on the client (or enable **Skip TLS verify** in the profile / interactive setup). | | Plugin installation fails | Check registry URL (`MARCHAT_PLUGIN_REGISTRY_URL`), network access, and JSON validity; commercial plugins need a valid license for the **plugin name** (see **PLUGIN_ECOSYSTEM.md**). | diff --git a/TESTING.md b/TESTING.md index be52dfa..878e1f2 100644 --- a/TESTING.md +++ b/TESTING.md @@ -36,7 +36,7 @@ The Marchat test suite provides foundational coverage of the application's core | `client/config/interactive_ui_test.go` | Client interactive UI components | TUI forms, profile selection, authentication prompts | | `client/code_snippet_test.go` | Client code snippet functionality | Text editing, selection, clipboard, syntax highlighting | | `client/file_picker_test.go` | Client file picker functionality | File browsing, selection, size validation, directory navigation | -| `client/main_test.go` | Client main functionality | Message rendering, user lists, URL handling, encryption functions, flag validation | +| `client/main_test.go` | Client main functionality | Message rendering, user lists, URL handling, encryption functions, flag validation, `wsConnected` transcript reset on reconnect, `TestMessageIncrementsUnread` | | `client/websocket_sanitize_test.go` | WebSocket URL / TLS hints | Sanitization helpers for display and connection hints | | `client/exthook/exthook_test.go` | Client hook helpers | Executable validation, hook JSON shaping, path rules | | `internal/doctor/db_checks_test.go` | Doctor DB probes | SQLite connectivity and version checks used by `-doctor` | @@ -55,7 +55,7 @@ The Marchat test suite provides foundational coverage of the application's core | `server/db_ci_smoke_test.go` | CI DB smoke | Postgres/MySQL `InitDB`, `CreateSchema`, core tables (env-gated) | | `server/message_state_test.go` | Durable reactions | Reaction persistence and replay helpers | | `server/config_test.go` | Server configuration | Server configuration logic and validation | -| `server/client_test.go` | Server client management | WebSocket client initialization, message handling, admin operations | +| `server/client_test.go` | Server client management | WebSocket client initialization, message handling, admin operations, unknown admin command system reply (`TestHandleCommandUnknownAdminSendsSystemReply`) | | `server/health_test.go` | Server health monitoring | Health checks, system metrics, HTTP endpoints, concurrent access | | `plugin/sdk/plugin_test.go` | Plugin SDK | Message types, extended fields (channel, encrypted, message_id, recipient, edited), JSON serialization, omitempty validation, backwards-compat unknown-field handling | | `plugin/sdk/stdio_test.go` | Plugin SDK stdio | `HandlePluginRequest` / `RunIO` (init, message, command, shutdown), EOF handling | @@ -83,7 +83,7 @@ Per-file statement percentages for important paths are listed under [Test Covera - **Client Config**: Configuration loading/saving, path utilities, keystore migration - **Client Interactive UI**: TUI forms, profile selection, authentication prompts, navigation, validation - **Client Code Snippet**: Text editing, selection, clipboard operations, syntax highlighting, state management -- **Client Main**: Message rendering, user lists, URL handling, encryption functions, flag validation +- **Client Main**: Message rendering, user lists, URL handling, encryption functions, flag validation, reconnect transcript handling (`TestWsConnectedClearsTranscript`) - **Client WebSocket helpers**: URL / TLS hint sanitization (`websocket_sanitize_test.go`) - **Client Hooks (`client/exthook`)**: Executable path validation and hook-safe JSON for send/receive events (`exthook_test.go`) - **Client File Picker**: File browsing, directory navigation, file selection, size validation, error handling diff --git a/THEMES.md b/THEMES.md index 7484ae3..cd59d0a 100644 --- a/THEMES.md +++ b/THEMES.md @@ -85,6 +85,12 @@ Marchat comes with 4 built-in themes: | `help_overlay_fg` | Help menu text | | `help_overlay_border` | Help menu border | | `help_title` | Help menu title color | +| `banner_error_bg` | Optional. Full-width banner strip background when the line starts with `[ERROR]` | +| `banner_error_fg` | Optional. Text color for the error strip (pair with `banner_error_bg`) | +| `banner_warn_bg` | Optional. Background when the line starts with `[WARN]` | +| `banner_warn_fg` | Optional. Foreground for the warn strip | +| `banner_info_bg` | Optional. Background for all other banner lines (`[OK]`, toggles, sending, and plain text) | +| `banner_info_fg` | Optional. Foreground for the info strip. If `banner_info_bg` and `banner_info_fg` are both omitted, the info strip reuses `footer_bg` and `footer_fg` | ## Using Custom Themes diff --git a/client/chrome.go b/client/chrome.go new file mode 100644 index 0000000..0b78003 --- /dev/null +++ b/client/chrome.go @@ -0,0 +1,165 @@ +// Chrome: footer vs banner rules for the main chat TUI. +// +// Footer shows stable connection state and a few predictable segments (unread +// count, optional E2E label, optional non-default channel). Do not put +// timer-driven or one-off strings in the footer. +// +// Banner strip above the transcript uses themeStyles banner strip styles: +// error, warn, and info bands keyed by [ERROR], [WARN], and everything else. +// Keep one surface per concern so users are not confused by duplicate or +// vanishing status text. +package main + +import ( + "fmt" + "strings" + "time" + + "github.com/Cod-e-Codes/marchat/shared" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const readReceiptDebounce = 750 * time.Millisecond + +// chromeFullWidth matches header and footer: chat viewport plus user list plus gap. +func chromeFullWidth(viewportW int) int { + return viewportW + userListWidth + 4 +} + +// layoutBannerForStrip collapses newlines to spaces and truncates to one line so a +// long [ERROR] path does not consume most of the terminal height under JoinVertical. +func layoutBannerForStrip(text string, width int) string { + if width < 24 { + width = 24 + } + s := strings.TrimSpace(strings.ReplaceAll(text, "\n", " ")) + s = strings.Join(strings.Fields(s), " ") + max := width - 3 + if max < 16 { + max = 16 + } + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max-3]) + "..." +} + +// bannerStripKind selects full-width banner strip colors in View. +type bannerStripKind int + +const ( + bannerStripInfo bannerStripKind = iota + bannerStripWarn + bannerStripError +) + +// stripKindForBanner maps banner text (including optional " [Sending...]" suffix) +// to a strip kind. Prefixes are case-sensitive to match existing banner strings. +func stripKindForBanner(bannerText string) bannerStripKind { + t := strings.TrimSpace(bannerText) + if strings.HasPrefix(t, "[ERROR]") { + return bannerStripError + } + if strings.HasPrefix(t, "[WARN]") { + return bannerStripWarn + } + return bannerStripInfo +} + +// BannerStrip returns the lipgloss style for the full-width status strip. +func (s themeStyles) BannerStrip(kind bannerStripKind) lipgloss.Style { + switch kind { + case bannerStripError: + return s.BannerError + case bannerStripWarn: + return s.BannerWarn + default: + return s.BannerInfo + } +} + +// buildStatusFooter returns a single footer line: connection, optional unread, +// optional E2E when enabled, optional channel when not general, and help text +// only when disconnected or when help overlay is open (stable while open). +func buildStatusFooter(connected, showHelp bool, unread int, useE2E bool, currentChannel string) string { + var parts []string + if connected { + parts = append(parts, "Connected") + } else { + parts = append(parts, "Disconnected") + } + if unread > 0 { + parts = append(parts, fmt.Sprintf("%d unread", unread)) + } + if useE2E { + parts = append(parts, "E2E") + } + ch := strings.TrimSpace(strings.ToLower(currentChannel)) + if ch != "" && ch != "general" { + parts = append(parts, "#"+ch) + } + if showHelp && connected { + parts = append(parts, "Press Ctrl+H to close help") + } else if !connected { + parts = append(parts, "Press Ctrl+H for help") + } + return strings.Join(parts, " | ") +} + +// maxMessageID returns the largest message_id in the transcript, or 0 if none. +func maxMessageID(msgs []shared.Message) int64 { + var max int64 + for i := range msgs { + if msgs[i].MessageID > max { + max = msgs[i].MessageID + } + } + return max +} + +// scheduleReadReceiptFlush debounces outbound read_receipt while the viewport +// is pinned to the tail. Coalesces bursts into one send after readReceiptDebounce. +func (m *model) scheduleReadReceiptFlush() tea.Cmd { + if m.conn == nil || !m.connected || !m.viewport.AtBottom() { + return nil + } + maxID := maxMessageID(m.messages) + if maxID == 0 || maxID <= m.lastReadReceiptSentID { + return nil + } + if m.readReceiptFlushScheduled { + return nil + } + m.readReceiptFlushScheduled = true + return tea.Tick(readReceiptDebounce, func(time.Time) tea.Msg { + return readReceiptFlushMsg{} + }) +} + +// flushReadReceipt sends a single read_receipt for the latest message id at +// the tail. On failure, sets banner and leaves lastReadReceiptSentID unchanged. +func (m *model) flushReadReceipt() tea.Cmd { + if m.conn == nil || !m.connected || !m.viewport.AtBottom() { + return nil + } + maxID := maxMessageID(m.messages) + if maxID == 0 || maxID <= m.lastReadReceiptSentID { + return nil + } + out := shared.Message{ + Type: shared.ReadReceiptType, + Sender: m.cfg.Username, + MessageID: maxID, + } + if err := m.conn.WriteJSON(out); err != nil { + m.banner = "[ERROR] Failed to send read receipt: " + err.Error() + return nil + } + m.lastReadReceiptSentID = maxID + if mid := maxMessageID(m.messages); mid > m.lastReadReceiptSentID && m.viewport.AtBottom() { + return m.scheduleReadReceiptFlush() + } + return nil +} diff --git a/client/chrome_test.go b/client/chrome_test.go new file mode 100644 index 0000000..d1867e3 --- /dev/null +++ b/client/chrome_test.go @@ -0,0 +1,127 @@ +package main + +import ( + "strings" + "testing" + + "github.com/Cod-e-Codes/marchat/shared" +) + +func TestBuildStatusFooter(t *testing.T) { + tests := []struct { + name string + connected bool + showHelp bool + unread int + useE2E bool + currentChannel string + wantContains []string + wantNotContains []string + }{ + { + name: "connected_plain", + connected: true, + showHelp: false, + unread: 0, + useE2E: false, + currentChannel: "general", + wantContains: []string{"Connected"}, + wantNotContains: []string{"Unread", "E2E", "#general", "Ctrl+H", "Unencrypted", "Msg info"}, + }, + { + name: "disconnected_shows_help", + connected: false, + showHelp: false, + unread: 0, + useE2E: false, + currentChannel: "", + wantContains: []string{"Disconnected", "Press Ctrl+H for help"}, + wantNotContains: []string{"Msg info"}, + }, + { + name: "help_open_connected", + connected: true, + showHelp: true, + unread: 0, + useE2E: false, + currentChannel: "general", + wantContains: []string{"Connected", "Press Ctrl+H to close help"}, + wantNotContains: []string{"Press Ctrl+H for help"}, + }, + { + name: "unread_e2e_channel", + connected: true, + showHelp: false, + unread: 3, + useE2E: true, + currentChannel: "dev", + wantContains: []string{"Connected", "3 unread", "E2E", "#dev"}, + wantNotContains: []string{"Unencrypted", "Msg info"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildStatusFooter(tt.connected, tt.showHelp, tt.unread, tt.useE2E, tt.currentChannel) + for _, s := range tt.wantContains { + if !strings.Contains(got, s) { + t.Errorf("footer %q should contain %q", got, s) + } + } + for _, s := range tt.wantNotContains { + if strings.Contains(got, s) { + t.Errorf("footer %q should not contain %q", got, s) + } + } + }) + } +} + +func TestStripKindForBanner(t *testing.T) { + tests := []struct { + text string + want bannerStripKind + }{ + {"", bannerStripInfo}, + {"[OK] Connected", bannerStripInfo}, + {"Msg info: full", bannerStripInfo}, + {" [WARN] clipboard", bannerStripWarn}, + {"[WARN] Connection lost", bannerStripWarn}, + {"[ERROR] failed", bannerStripError}, + {"[ERROR] x [Sending...]", bannerStripError}, + } + for _, tt := range tests { + t.Run(tt.text, func(t *testing.T) { + if got := stripKindForBanner(tt.text); got != tt.want { + t.Fatalf("stripKindForBanner(%q) = %v, want %v", tt.text, got, tt.want) + } + }) + } +} + +func TestLayoutBannerForStrip(t *testing.T) { + long := "[ERROR] Failed to read file: open " + strings.Repeat("x", 200) + ": no such file" + out := layoutBannerForStrip(long, 80) + if strings.Contains(out, "\n") { + t.Fatal("banner layout must be single line") + } + if len([]rune(out)) > 80 { + t.Fatalf("expected truncation under width, got len %d", len([]rune(out))) + } + if !strings.HasSuffix(out, "...") { + t.Fatal("expected ellipsis for long banner") + } +} + +func TestMaxMessageID(t *testing.T) { + msgs := []shared.Message{ + {MessageID: 10}, + {MessageID: 2}, + {MessageID: 99}, + } + if id := maxMessageID(msgs); id != 99 { + t.Fatalf("maxMessageID = %d, want 99", id) + } + if id := maxMessageID(nil); id != 0 { + t.Fatalf("maxMessageID(nil) = %d, want 0", id) + } +} diff --git a/client/code_snippet_test.go b/client/code_snippet_test.go index 89fe6d4..8739e58 100644 --- a/client/code_snippet_test.go +++ b/client/code_snippet_test.go @@ -11,10 +11,16 @@ import ( // Mock themeStyles for testing func getMockThemeStyles() themeStyles { return themeStyles{ - User: lipgloss.NewStyle(), - Msg: lipgloss.NewStyle(), - Time: lipgloss.NewStyle(), - Banner: lipgloss.NewStyle(), + User: lipgloss.NewStyle(), + Msg: lipgloss.NewStyle(), + Time: lipgloss.NewStyle(), + Banner: lipgloss.NewStyle(), + BannerError: lipgloss.NewStyle(), + BannerWarn: lipgloss.NewStyle(), + BannerInfo: lipgloss.NewStyle(), + SystemMsg: lipgloss.NewStyle(), + SystemMsgError: lipgloss.NewStyle(), + SystemMsgWarn: lipgloss.NewStyle(), } } diff --git a/client/commands.go b/client/commands.go index d1d56c0..b0f59b5 100644 --- a/client/commands.go +++ b/client/commands.go @@ -17,6 +17,7 @@ func (m *model) generateHelpContent() string { } else { sessionInfo = "Session: Unencrypted (messages are sent in plain text)\n" } + sessionInfo += "Status bar: connection, unread when any, E2E when on, current channel when not #general. When disconnected, a Ctrl+H hint is shown. Command results, :msginfo ack, and errors use the banner above the transcript.\n" shortcuts := "\nKeyboard Shortcuts:\n" shortcuts += " Ctrl+H Toggle this help\n" diff --git a/client/main.go b/client/main.go index 6dab6ed..544c8a4 100644 --- a/client/main.go +++ b/client/main.go @@ -197,6 +197,12 @@ type model struct { reactions map[int64]map[string]map[string]bool lastTypingSent time.Time unreadCount int + + // Hub channel (lowercase); empty means unknown or general for display. + currentChannel string + // Read receipts: debounced tail cursor sent to the server. + lastReadReceiptSentID int64 + readReceiptFlushScheduled bool } // configToNotificationConfig converts Config to NotificationConfig @@ -288,16 +294,45 @@ func (m *model) shouldNotify(msg shared.Message) (bool, NotificationLevel) { return true, NotificationLevelInfo } +// messageIncrementsUnread is true when an inbound message should bump the footer +// unread count while the transcript viewport is not at the bottom. Ephemeral or +// in-place update types (typing, reactions, edits, etc.) must not increment. +func messageIncrementsUnread(m *model, v shared.Message) bool { + if v.Sender == m.cfg.Username { + return false + } + switch v.Type { + case shared.TypingMessage, shared.ReadReceiptType, shared.ReactionMessage, + shared.EditMessageType, shared.DeleteMessage, shared.PinMessage, + shared.SearchMessage, shared.AdminCommandType, + shared.JoinChannelType, shared.LeaveChannelType, shared.ListChannelsType: + return false + case shared.TextMessage, shared.DirectMessage, shared.FileMessageType: + return true + case "": + return true + default: + return false + } +} + type themeStyles struct { - User lipgloss.Style - Time lipgloss.Style - Info lipgloss.Style // empty state, date headers, system lines - Timestamp lipgloss.Style // bracketed message times in transcript - Msg lipgloss.Style - Banner lipgloss.Style - Box lipgloss.Style // frame color - Mention lipgloss.Style // mention highlighting - Hyperlink lipgloss.Style // hyperlink highlighting + User lipgloss.Style + Time lipgloss.Style + Info lipgloss.Style // empty state and date headers (not System transcript body) + Timestamp lipgloss.Style // bracketed message times in transcript + Msg lipgloss.Style + Banner lipgloss.Style // accent foreground (inline highlights); not the full-width strip + BannerError lipgloss.Style + BannerWarn lipgloss.Style + BannerInfo lipgloss.Style + // Transcript: lines from sender "System" (distinct from top banner strip). + SystemMsg lipgloss.Style + SystemMsgError lipgloss.Style + SystemMsgWarn lipgloss.Style + Box lipgloss.Style // frame color + Mention lipgloss.Style // mention highlighting + Hyperlink lipgloss.Style // hyperlink highlighting UserList lipgloss.Style // NEW: user list panel Me lipgloss.Style // NEW: current user style @@ -317,22 +352,28 @@ type themeStyles struct { func baseThemeStyles() themeStyles { timeStyle := lipgloss.NewStyle().Faint(true) return themeStyles{ - User: lipgloss.NewStyle().Bold(true), - Time: timeStyle, - Info: timeStyle, - Timestamp: timeStyle, - Msg: lipgloss.NewStyle(), - Banner: lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5F5F")).Bold(true), - Box: lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("#AAAAAA")), - Mention: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFD700")), - Hyperlink: lipgloss.NewStyle().Underline(true).Foreground(lipgloss.Color("#4A9EFF")), - UserList: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("#AAAAAA")).Padding(0, 1), - Me: lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD700")).Bold(true), - Other: lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")), - Background: lipgloss.NewStyle(), - Header: lipgloss.NewStyle(), - Footer: lipgloss.NewStyle(), - Input: lipgloss.NewStyle(), + User: lipgloss.NewStyle().Bold(true), + Time: timeStyle, + Info: timeStyle, + Timestamp: timeStyle, + Msg: lipgloss.NewStyle(), + Banner: lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5F5F")).Bold(true), + BannerError: lipgloss.NewStyle().Background(lipgloss.Color("#C42B2B")).Foreground(lipgloss.Color("#FFFFFF")).Bold(true), + BannerWarn: lipgloss.NewStyle().Background(lipgloss.Color("#B8860B")).Foreground(lipgloss.Color("#000000")).Bold(true), + BannerInfo: lipgloss.NewStyle().Background(lipgloss.Color("#2D4A68")).Foreground(lipgloss.Color("#E8E8E8")).Bold(true), + SystemMsg: timeStyle, + SystemMsgError: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FF5555")), + SystemMsgWarn: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#D7AF00")), + Box: lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("#AAAAAA")), + Mention: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFD700")), + Hyperlink: lipgloss.NewStyle().Underline(true).Foreground(lipgloss.Color("#4A9EFF")), + UserList: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("#AAAAAA")).Padding(0, 1), + Me: lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD700")).Bold(true), + Other: lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")), + Background: lipgloss.NewStyle(), + Header: lipgloss.NewStyle(), + Footer: lipgloss.NewStyle(), + Input: lipgloss.NewStyle(), HelpOverlay: lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("#FFFFFF")). @@ -346,6 +387,63 @@ func baseThemeStyles() themeStyles { } } +// applyBuiltinBannerStrips sets full-width banner strip styles per built-in theme. +func applyBuiltinBannerStrips(s *themeStyles, theme string) { + switch theme { + case "system": + s.BannerError = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FF5555")) + s.BannerWarn = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#D7AF00")) + s.BannerInfo = lipgloss.NewStyle().Bold(true) + case "patriot": + // BannerError must differ from header red (#BF0A30) so errors read as their own band. + s.BannerError = lipgloss.NewStyle().Background(lipgloss.Color("#5C1018")).Foreground(lipgloss.Color("#FFFFFF")).Bold(true) + s.BannerWarn = lipgloss.NewStyle().Background(lipgloss.Color("#FFD700")).Foreground(lipgloss.Color("#002868")).Bold(true) + s.BannerInfo = lipgloss.NewStyle().Background(lipgloss.Color("#002868")).Foreground(lipgloss.Color("#FFFFFF")).Bold(true) + case "retro": + s.BannerError = lipgloss.NewStyle().Background(lipgloss.Color("#CC2200")).Foreground(lipgloss.Color("#FFFFFF")).Bold(true) + // Avoid same fill as header orange strip. + s.BannerWarn = lipgloss.NewStyle().Background(lipgloss.Color("#CC6600")).Foreground(lipgloss.Color("#181818")).Bold(true) + s.BannerInfo = lipgloss.NewStyle().Background(lipgloss.Color("#222200")).Foreground(lipgloss.Color("#FFFFAA")).Bold(true) + case "modern": + s.BannerError = lipgloss.NewStyle().Background(lipgloss.Color("#D32F2F")).Foreground(lipgloss.Color("#FFFFFF")).Bold(true) + s.BannerWarn = lipgloss.NewStyle().Background(lipgloss.Color("#F9A825")).Foreground(lipgloss.Color("#000000")).Bold(true) + s.BannerInfo = lipgloss.NewStyle().Background(lipgloss.Color("#23272E")).Foreground(lipgloss.Color("#E0E0E0")).Bold(true) + } +} + +// applySemanticStylesForTheme sets date-divider Info (not always equal to chat +// timestamp color) and System transcript line styles so errors are not the +// same color as normal system text. +func applySemanticStylesForTheme(s *themeStyles, theme string) { + switch theme { + case "patriot": + s.Info = lipgloss.NewStyle().Faint(true).Foreground(lipgloss.Color("#8FA3B8")) + s.SystemMsg = lipgloss.NewStyle().Foreground(lipgloss.Color("#E8EAED")) + s.SystemMsgError = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF8A80")).Bold(true) + s.SystemMsgWarn = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD54F")).Bold(true) + case "retro": + s.Info = lipgloss.NewStyle().Faint(true).Foreground(lipgloss.Color("#66AA66")) + s.SystemMsg = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFCC")) + s.SystemMsgError = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6E6E")).Bold(true) + s.SystemMsgWarn = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFF00")).Bold(true) + case "modern": + s.Info = lipgloss.NewStyle().Faint(true).Foreground(lipgloss.Color("#78909C")) + s.SystemMsg = lipgloss.NewStyle().Foreground(lipgloss.Color("#B0BEC5")) + s.SystemMsgError = lipgloss.NewStyle().Foreground(lipgloss.Color("#EF5350")).Bold(true) + s.SystemMsgWarn = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFCA28")).Bold(true) + case "system": + s.Info = s.Time + s.SystemMsg = s.Time + s.SystemMsgError = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FF5555")) + s.SystemMsgWarn = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#D7AF00")) + default: + s.Info = s.Time + s.SystemMsg = s.Time + s.SystemMsgError = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FF5555")) + s.SystemMsgWarn = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#D7AF00")) + } +} + func getThemeStyles(theme string) themeStyles { // Check for custom themes first if IsCustomTheme(theme) { @@ -421,8 +519,10 @@ func getThemeStyles(theme string) themeStyles { s.Input = lipgloss.NewStyle().Background(lipgloss.Color("#23272E")).Foreground(lipgloss.Color("#E0E0E0")) s.HelpOverlay = s.HelpOverlay.BorderForeground(lipgloss.Color("#4F8EF7")).Background(lipgloss.Color("#181C24")) } + themeKey := strings.ToLower(theme) + applyBuiltinBannerStrips(&s, themeKey) s.Timestamp = s.Time - s.Info = s.Time + applySemanticStylesForTheme(&s, themeKey) return s } @@ -459,7 +559,20 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.connected = true m.banner = "[OK] Connected to server." m.reconnectDelay = time.Second // reset on success + m.lastReadReceiptSentID = 0 + m.readReceiptFlushScheduled = false + // Server replays history on each handshake; drop local transcript so we do + // not duplicate messages after a disconnect or server restart. + m.messages = nil + m.reactions = make(map[int64]map[string]map[string]bool) + m.typingUsers = make(map[string]time.Time) + m.receivedFiles = nil + m.unreadCount = 0 + m.viewport.SetContent(renderMessages(m.messages, m.styles, m.cfg.Username, m.users, m.viewport.Width, m.twentyFourHour, m.showMessageMetadata, m.reactions)) return m, m.listenWebSocket() + case readReceiptFlushMsg: + m.readReceiptFlushScheduled = false + return m, m.flushReadReceipt() case wsReaderClosed: return m, nil case wsMsg: @@ -586,6 +699,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.showFilePicker = false return m, m.listenWebSocket() case shared.Message: + if ch := strings.TrimSpace(v.Channel); ch != "" { + m.currentChannel = strings.ToLower(ch) + } if shouldNotify, level := m.shouldNotify(v); shouldNotify { m.notificationManager.Notify(v.Sender, v.Content, level) } @@ -652,11 +768,17 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if wasAtBottom { m.viewport.GotoBottom() m.unreadCount = 0 - } else { + } else if messageIncrementsUnread(m, v) { m.unreadCount++ } m.sending = false - return m, m.listenWebSocket() + cmds := []tea.Cmd{m.listenWebSocket()} + if m.viewport.AtBottom() { + if rr := m.scheduleReadReceiptFlush(); rr != nil { + cmds = append(cmds, rr) + } + } + return m, tea.Batch(cmds...) case wsUsernameError: log.Printf("Handling wsUsernameError: %s", v.message) m.connected = false @@ -845,7 +967,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case key.Matches(v, m.keys.MessageInfoHotkey): m.showMessageMetadata = !m.showMessageMetadata - m.banner = "Message metadata: " + map[bool]string{true: "full", false: "minimal"}[m.showMessageMetadata] + m.banner = "Msg info: " + map[bool]string{true: "full", false: "minimal"}[m.showMessageMetadata] m.viewport.SetContent(renderMessages(m.messages, m.styles, m.cfg.Username, m.users, m.viewport.Width, m.twentyFourHour, m.showMessageMetadata, m.reactions)) return m, nil case key.Matches(v, m.keys.ClearHotkey): @@ -954,6 +1076,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if m.viewport.AtBottom() { m.unreadCount = 0 + if rr := m.scheduleReadReceiptFlush(); rr != nil { + return m, rr + } } return m, nil case key.Matches(v, m.keys.PageUp): @@ -971,6 +1096,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if m.viewport.AtBottom() { m.unreadCount = 0 + if rr := m.scheduleReadReceiptFlush(); rr != nil { + return m, rr + } } return m, nil case key.Matches(v, m.keys.Copy): // Custom Copy @@ -1340,7 +1468,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if text == ":msginfo" { m.showMessageMetadata = !m.showMessageMetadata - m.banner = "Message metadata: " + map[bool]string{true: "full", false: "minimal"}[m.showMessageMetadata] + m.banner = "Msg info: " + map[bool]string{true: "full", false: "minimal"}[m.showMessageMetadata] m.viewport.SetContent(renderMessages(m.messages, m.styles, m.cfg.Username, m.users, m.viewport.Width, m.twentyFourHour, m.showMessageMetadata, m.reactions)) m.viewport.GotoBottom() m.textarea.SetValue("") @@ -1721,6 +1849,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if strings.HasPrefix(text, ":join ") { channel := strings.TrimSpace(strings.TrimPrefix(text, ":join ")) if channel != "" { + m.currentChannel = strings.ToLower(channel) joinMsg := shared.Message{ Type: shared.JoinChannelType, Sender: m.cfg.Username, @@ -1732,6 +1861,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } if text == ":leave" { + m.currentChannel = "general" leaveMsg := shared.Message{ Type: shared.LeaveChannelType, Sender: m.cfg.Username, @@ -1800,6 +1930,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.listenWebSocket() } m.banner = "" + // Do not wait for a chat echo: unknown or silent server paths used to leave + // m.sending true forever because only shared.Message clears it. + m.sending = false } else if m.dmRecipient != "" { dmMsg := shared.Message{ Type: shared.DirectMessage, @@ -1814,6 +1947,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.listenWebSocket() } m.banner = "" + m.sending = false } else if m.useE2E { log.Printf("DEBUG: Attempting to send global encrypted message: '%s'", text) @@ -1847,6 +1981,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { log.Printf("DEBUG: Global encrypted message sent successfully") m.banner = "" + m.sending = false } else { // Send plain text message msg := shared.Message{Sender: m.cfg.Username, Content: text} @@ -1857,6 +1992,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.listenWebSocket() } m.banner = "" + m.sending = false } } m.textarea.SetValue("") @@ -1984,23 +2120,7 @@ func (m *model) View() string { headerText := fmt.Sprintf(" marchat %s ", shared.ClientVersion) header := m.styles.Header.Width(m.viewport.Width + userListWidth + 4).Render(headerText) - connStatus := "Disconnected" - if m.connected { - connStatus = "Connected" - } - footerText := connStatus + " | Press Ctrl+H for help" - if m.showHelp { - footerText = connStatus + " | Press Ctrl+H to close help" - } - if m.useE2E { - footerText += " | E2E Encrypted" - } else { - footerText += " | Unencrypted" - } - if m.unreadCount > 0 { - footerText += fmt.Sprintf(" | %d unread", m.unreadCount) - } - footerText += " | Msg info: " + map[bool]string{true: "full", false: "minimal"}[m.showMessageMetadata] + footerText := buildStatusFooter(m.connected, m.showHelp, m.unreadCount, m.useE2E, m.currentChannel) footer := m.styles.Footer.Width(m.viewport.Width + userListWidth + 4).Render(footerText) // Banner @@ -2014,13 +2134,13 @@ func (m *model) View() string { bannerText = "[Sending...]" } } - bannerBox = m.styles.Banner. - Width(m.viewport.Width). + kind := stripKindForBanner(bannerText) + fullW := chromeFullWidth(m.viewport.Width) + bannerShown := layoutBannerForStrip(bannerText, fullW) + bannerBox = m.styles.BannerStrip(kind). + Width(fullW). PaddingLeft(1). - Background(lipgloss.Color("#FF5F5F")). - Foreground(lipgloss.Color("#000000")). - Bold(true). - Render(bannerText) + Render(bannerShown) } // Chat and user list layout diff --git a/client/main_test.go b/client/main_test.go index a05d356..30bbb22 100644 --- a/client/main_test.go +++ b/client/main_test.go @@ -11,9 +11,89 @@ import ( "testing" "time" + "github.com/Cod-e-Codes/marchat/client/config" "github.com/Cod-e-Codes/marchat/shared" + "github.com/charmbracelet/bubbles/viewport" ) +func TestMessageIncrementsUnread(t *testing.T) { + m := &model{cfg: config.Config{Username: "me"}} + tests := []struct { + name string + msg shared.Message + want bool + }{ + {"own_text", shared.Message{Sender: "me", Type: shared.TextMessage}, false}, + {"other_text", shared.Message{Sender: "you", Type: shared.TextMessage}, true}, + {"typing", shared.Message{Sender: "you", Type: shared.TypingMessage}, false}, + {"reaction", shared.Message{Sender: "you", Type: shared.ReactionMessage}, false}, + {"read_receipt", shared.Message{Sender: "you", Type: shared.ReadReceiptType}, false}, + {"edit", shared.Message{Sender: "you", Type: shared.EditMessageType}, false}, + {"delete", shared.Message{Sender: "you", Type: shared.DeleteMessage}, false}, + {"other_dm", shared.Message{Sender: "you", Type: shared.DirectMessage}, true}, + {"other_file", shared.Message{Sender: "you", Type: shared.FileMessageType}, true}, + {"legacy_empty_type", shared.Message{Sender: "you", Type: ""}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := messageIncrementsUnread(m, tt.msg); got != tt.want { + t.Errorf("got %v want %v", got, tt.want) + } + }) + } +} + +func TestWsConnectedClearsTranscript(t *testing.T) { + vp := viewport.New(80, 20) + vp.SetContent("stale viewport body") + m := &model{ + cfg: config.Config{Username: "alice", Theme: "retro"}, + viewport: vp, + styles: getThemeStyles("retro"), + users: []string{"alice"}, + messages: []shared.Message{{Sender: "alice", Content: "before reconnect", MessageID: 1}}, + reactions: map[int64]map[string]map[string]bool{1: {"+1": {"bob": true}}}, + typingUsers: map[string]time.Time{ + "bob": time.Now(), + }, + receivedFiles: map[string]*shared.FileMeta{ + "alice/file.txt": {Filename: "file.txt"}, + }, + unreadCount: 5, + } + + next, cmd := m.Update(wsConnected{}) + if cmd == nil { + t.Fatal("expected non-nil cmd from wsConnected") + } + m2, ok := next.(*model) + if !ok { + t.Fatalf("Update returned %T, want *model", next) + } + if len(m2.messages) != 0 { + t.Errorf("messages len: got %d, want 0", len(m2.messages)) + } + if len(m2.reactions) != 0 { + t.Errorf("reactions len: got %d, want 0", len(m2.reactions)) + } + if len(m2.typingUsers) != 0 { + t.Errorf("typingUsers len: got %d, want 0", len(m2.typingUsers)) + } + if m2.receivedFiles != nil { + t.Errorf("receivedFiles: want nil, got %+v", m2.receivedFiles) + } + if m2.unreadCount != 0 { + t.Errorf("unreadCount: got %d, want 0", m2.unreadCount) + } + if !m2.connected { + t.Error("connected: want true") + } + body := m2.viewport.View() + if strings.Contains(body, "before reconnect") || strings.Contains(body, "stale viewport body") { + t.Errorf("viewport should not retain pre-reconnect content; got %q", body) + } +} + func TestMainFunctionExists(t *testing.T) { // This test ensures the main function exists and can be called // We can't actually call main() in tests, but we can verify the package compiles diff --git a/client/render.go b/client/render.go index 06b5716..776558a 100644 --- a/client/render.go +++ b/client/render.go @@ -27,6 +27,50 @@ func sortMessagesByTimestamp(messages []shared.Message) { }) } +type systemLineSeverity int + +const ( + systemLineInfo systemLineSeverity = iota + systemLineWarn + systemLineErr +) + +// systemLineSeverityClass classifies System sender content for transcript coloring. +func systemLineSeverityClass(content string) systemLineSeverity { + t := strings.TrimSpace(content) + tl := strings.ToLower(t) + switch { + case strings.HasPrefix(tl, "[error]"): + return systemLineErr + case strings.HasPrefix(tl, "[warn]"): + return systemLineWarn + case strings.HasPrefix(tl, "unknown "), + strings.HasPrefix(tl, "invalid "), + tl == "error", + strings.HasPrefix(tl, "error "), + strings.HasPrefix(tl, "error:"), + strings.Contains(tl, " not found"), + strings.Contains(tl, " not allowed"), + strings.Contains(tl, "failed"): + return systemLineErr + default: + return systemLineInfo + } +} + +// systemLineStyle picks transcript styling for Server "System" lines so errors +// and warnings are not the same color as normal notices. +func systemLineStyle(styles themeStyles, content string) lipgloss.Style { + switch systemLineSeverityClass(content) { + case systemLineErr: + return styles.SystemMsgError + case systemLineWarn: + return styles.SystemMsgWarn + default: + return styles.SystemMsg + } +} + func renderMessages(msgs []shared.Message, styles themeStyles, username string, users []string, width int, twentyFourHour bool, showMessageMetadata bool, reactions ...map[int64]map[string]map[string]bool) string { var reactionMap map[int64]map[string]map[string]bool if len(reactions) > 0 { @@ -104,7 +148,7 @@ func renderMessages(msgs []shared.Message, styles themeStyles, username string, switch msg.Sender { case "System": - b.WriteString(timestamp + " " + prefix + styles.Info.Render(content) + metaSuffix + "\n") + b.WriteString(timestamp + " " + prefix + systemLineStyle(styles, content).Render(content) + metaSuffix + "\n") case username: b.WriteString(timestamp + " " + prefix + styles.Me.Render(msg.Sender) + ": " + content + metaSuffix + "\n") default: diff --git a/client/render_test.go b/client/render_test.go new file mode 100644 index 0000000..dfa7a11 --- /dev/null +++ b/client/render_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "strings" + "testing" + "time" + + "github.com/Cod-e-Codes/marchat/shared" +) + +func TestSystemLineSeverityClass(t *testing.T) { + tests := []struct { + content string + want systemLineSeverity + }{ + {"Plugin ok", systemLineInfo}, + {"Unknown plugin subcommand", systemLineErr}, + {"[ERROR] x", systemLineErr}, + {"[WARN] x", systemLineWarn}, + {"invalid input", systemLineErr}, + {"Operation failed", systemLineErr}, + {"No failure here", systemLineInfo}, + } + for _, tt := range tests { + if got := systemLineSeverityClass(tt.content); got != tt.want { + t.Fatalf("%q: got %v want %v", tt.content, got, tt.want) + } + } +} + +func TestRenderMessagesSystemUsesSemanticStyle(t *testing.T) { + now := time.Now() + msgs := []shared.Message{ + {Sender: "System", Content: "All quiet", CreatedAt: now, Type: shared.TextMessage}, + {Sender: "System", Content: "Unknown command", CreatedAt: now.Add(time.Second), Type: shared.TextMessage}, + } + styles := getThemeStyles("patriot") + out := renderMessages(msgs, styles, "u", []string{"u"}, 80, true, false) + if !strings.Contains(out, "All quiet") || !strings.Contains(out, "Unknown command") { + t.Fatalf("expected both lines in output: %q", out) + } +} diff --git a/client/theme_loader.go b/client/theme_loader.go index a5da5f6..c12ed82 100644 --- a/client/theme_loader.go +++ b/client/theme_loader.go @@ -34,6 +34,13 @@ type ThemeColors struct { HelpOverlayFg string `json:"help_overlay_fg"` HelpOverlayBorder string `json:"help_overlay_border"` HelpTitle string `json:"help_title"` + // Optional full-width banner strip (above transcript). Empty uses defaults; info strip falls back to footer colors. + BannerErrorBg string `json:"banner_error_bg,omitempty"` + BannerErrorFg string `json:"banner_error_fg,omitempty"` + BannerWarnBg string `json:"banner_warn_bg,omitempty"` + BannerWarnFg string `json:"banner_warn_fg,omitempty"` + BannerInfoBg string `json:"banner_info_bg,omitempty"` + BannerInfoFg string `json:"banner_info_fg,omitempty"` } // ThemeDefinition represents a complete theme with metadata @@ -123,9 +130,49 @@ func ApplyCustomTheme(def ThemeDefinition) themeStyles { Bold(true). MarginBottom(1), } + be, bw, bi := customThemeBannerStrips(def.Colors) + s.BannerError, s.BannerWarn, s.BannerInfo = be, bw, bi + s.SystemMsg = lipgloss.NewStyle().Foreground(lipgloss.Color(def.Colors.Message)) + s.SystemMsgError = lipgloss.NewStyle().Foreground(lipgloss.Color(def.Colors.Banner)).Bold(true) + s.SystemMsgWarn = lipgloss.NewStyle().Foreground(lipgloss.Color(def.Colors.Mention)).Bold(true) return s } +// customThemeBannerStrips builds error, warn, and info banner strip styles from ThemeColors. +func customThemeBannerStrips(c ThemeColors) (lipgloss.Style, lipgloss.Style, lipgloss.Style) { + errBg, errFg := strings.TrimSpace(c.BannerErrorBg), strings.TrimSpace(c.BannerErrorFg) + if errBg == "" { + errBg = "#C42B2B" + } + if errFg == "" { + errFg = "#FFFFFF" + } + warnBg, warnFg := strings.TrimSpace(c.BannerWarnBg), strings.TrimSpace(c.BannerWarnFg) + if warnBg == "" { + warnBg = "#B8860B" + } + if warnFg == "" { + warnFg = "#000000" + } + infoBg, infoFg := strings.TrimSpace(c.BannerInfoBg), strings.TrimSpace(c.BannerInfoFg) + if infoBg == "" { + infoBg = strings.TrimSpace(c.FooterBg) + if infoBg == "" { + infoBg = "#2A2A2A" + } + } + if infoFg == "" { + infoFg = strings.TrimSpace(c.FooterFg) + if infoFg == "" { + infoFg = "#CCCCCC" + } + } + errStrip := lipgloss.NewStyle().Background(lipgloss.Color(errBg)).Foreground(lipgloss.Color(errFg)).Bold(true) + warnStrip := lipgloss.NewStyle().Background(lipgloss.Color(warnBg)).Foreground(lipgloss.Color(warnFg)).Bold(true) + infoStrip := lipgloss.NewStyle().Background(lipgloss.Color(infoBg)).Foreground(lipgloss.Color(infoFg)).Bold(true) + return errStrip, warnStrip, infoStrip +} + // IsCustomTheme checks if a theme name refers to a custom theme func IsCustomTheme(themeName string) bool { _, exists := customThemes[themeName] diff --git a/client/theme_loader_test.go b/client/theme_loader_test.go index e1fbca7..c9dc007 100644 --- a/client/theme_loader_test.go +++ b/client/theme_loader_test.go @@ -2,6 +2,7 @@ package main import ( "reflect" + "strings" "testing" ) @@ -26,3 +27,29 @@ func TestGetCustomThemeNamesSorted(t *testing.T) { t.Fatalf("ListAllThemes() = %v, want %v", all, wantAll) } } + +func TestCustomThemeBannerStripsFooterFallback(t *testing.T) { + c := ThemeColors{ + FooterBg: "#181C24", + FooterFg: "#4F8EF7", + } + _, _, info := customThemeBannerStrips(c) + out := info.Render("status") + if !strings.Contains(out, "status") { + t.Fatalf("expected render to contain text, got %q", out) + } +} + +func TestCustomThemeBannerStripsExplicitColors(t *testing.T) { + c := ThemeColors{ + FooterBg: "#181C24", + FooterFg: "#4F8EF7", + BannerErrorBg: "#010101", + BannerErrorFg: "#FEFEFE", + } + errS, _, _ := customThemeBannerStrips(c) + out := errS.Render("e") + if !strings.Contains(out, "e") { + t.Fatalf("expected render to contain text, got %q", out) + } +} diff --git a/client/websocket.go b/client/websocket.go index e0307f6..7a3add0 100644 --- a/client/websocket.go +++ b/client/websocket.go @@ -40,6 +40,9 @@ type UserList struct { type quitMsg struct{} +// readReceiptFlushMsg fires after debounce to send one coalesced read_receipt. +type readReceiptFlushMsg struct{} + // encryptGlobalTextWireContent returns base64(nonce ‖ ciphertext) for global chat E2E text, // matching the wire format produced for normal encrypted messages. func encryptGlobalTextWireContent(keystore *crypto.KeyStore, username, plaintext string) (string, error) { @@ -433,6 +436,7 @@ func (m *model) closeWebSocket() { m.conn.Close() m.conn = nil } + m.readReceiptFlushScheduled = false m.wg.Wait() } diff --git a/server/client.go b/server/client.go index 1c249ac..658cc34 100644 --- a/server/client.go +++ b/server/client.go @@ -88,6 +88,12 @@ func (c *Client) readPump() { if len(msgTimestamps) >= rateLimitMessages { log.Printf("Rate limit exceeded for client %s, cooldown %v", c.username, rateLimitCooldown) cooldownUntil = now.Add(rateLimitCooldown) + c.send <- shared.Message{ + Sender: "System", + Content: "Rate limited: too many messages in a short window. Wait before sending again.", + CreatedAt: time.Now(), + Type: shared.TextMessage, + } continue } msgTimestamps = append(msgTimestamps, now) @@ -703,6 +709,12 @@ func (c *Client) handleCommand(command string) { default: log.Printf("[ADMIN] Unknown admin command by %s: %s", c.username, command) + c.send <- shared.Message{ + Sender: "System", + Content: "Unknown command: " + parts[0], + CreatedAt: time.Now(), + Type: shared.TextMessage, + } } } diff --git a/server/client_test.go b/server/client_test.go index 4e886a7..4a90ada 100644 --- a/server/client_test.go +++ b/server/client_test.go @@ -519,3 +519,29 @@ func TestClient_HandleAdminCommand(t *testing.T) { client.handleCommand(":stats") // Should not panic or cause issues } + +func TestHandleCommandUnknownAdminSendsSystemReply(t *testing.T) { + client, _, _, cleanup := setupTestClient(t) + defer cleanup() + client.isAdmin = true + client.pluginCommandHandler = nil + client.handleCommand(":hello") + select { + case raw := <-client.send: + sm, ok := raw.(shared.Message) + if !ok { + t.Fatalf("expected shared.Message, got %T", raw) + } + if sm.Sender != "System" { + t.Errorf("sender: got %q, want System", sm.Sender) + } + if sm.Type != shared.TextMessage { + t.Errorf("type: got %q, want text", sm.Type) + } + if !strings.Contains(sm.Content, "Unknown command") || !strings.Contains(sm.Content, ":hello") { + t.Errorf("unexpected content: %q", sm.Content) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for reply on send channel") + } +} diff --git a/themes.example.json b/themes.example.json index 5d01310..c419328 100644 --- a/themes.example.json +++ b/themes.example.json @@ -23,7 +23,13 @@ "help_overlay_bg": "#1a1a1a", "help_overlay_fg": "#FFFFFF", "help_overlay_border": "#FFFFFF", - "help_title": "#FFD700" + "help_title": "#FFD700", + "banner_error_bg": "#D32F2F", + "banner_error_fg": "#FFFFFF", + "banner_warn_bg": "#F9A825", + "banner_warn_fg": "#000000", + "banner_info_bg": "#23272E", + "banner_info_fg": "#E0E0E0" } }, "cyberpunk": {