Skip to content

Commit 0a3a05c

Browse files
committed
fix(client,server): sending state after chat write; rate limit notice; unread count
1 parent c7583fe commit 0a3a05c

7 files changed

Lines changed: 66 additions & 7 deletions

File tree

ARCHITECTURE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ The client is a standalone terminal user interface built with the Bubble Tea fra
7070
- URL detection and external opening
7171
- Tab completion for @mentions
7272
- Connection status indicator
73-
- Unread message count
73+
- 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)
7474
- Optional debounced `read_receipt` to the server when the viewport follows the newest messages; failures surface in the banner only
7575
- 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
7676
- 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
@@ -104,7 +104,7 @@ The server is a standalone HTTP/WebSocket server application that provides real-
104104
- System metrics collection and health monitoring
105105
- Web-based admin panel with CSRF protection
106106
- Health check endpoints for monitoring systems
107-
- WebSocket message rate limiting
107+
- 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**)
108108
- **Diagnostics**: `-doctor` and `-doctor-json` without binding ports (`internal/doctor`)
109109

110110
### Server Library (`server/`)

PROTOCOL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ Per WebSocket connection, the server enforces rate limiting on **all** incoming
198198
- **Burst:** at most **20 messages** per **5 second** sliding window.
199199
- **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.
200200

201-
Exceeded messages are dropped silently from the client’s perspective (the server logs the event). Alternative clients should pace high-frequency traffic accordingly.
201+
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.
202202

203203
---
204204

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ Screen recordings of a current build (GIF autoplay depends on the viewer).
8787
- **Docker Support** - Containerized deployment with `docker-compose.yml` for local dev; optional **TLS reverse proxy** via Caddy ([guide](deploy/CADDY-REVERSE-PROXY.md))
8888
- **Health Monitoring** - `/health` and `/health/simple` endpoints with system metrics
8989
- **Structured Logging** - JSON logs with component separation and user tracking
90-
- **UX Enhancements** - Stable status footer (connection, unread, optional E2E and channel), banner for command feedback, tab completion for @mentions, multi-line input, chat export
90+
- **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
9191
- **Cross-Platform** - Runs on Linux, macOS, Windows, and Android/Termux
9292
- **Diagnostics** - `marchat-client -doctor` and `marchat-server -doctor` (or `-doctor-json`) summarize environment, resolved paths, and configuration health
9393

@@ -766,7 +766,8 @@ The TUI client can spawn **optional** external programs on send/receive and pass
766766
| SQL syntax error after backend switch | Ensure tables were created by the current server version and restart after changing `MARCHAT_DB_PATH`. |
767767
| 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. |
768768
| 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). |
769-
| Banner stuck on `[Sending...]` after a bogus `:` command | Use a current **client**: it clears the sending state after a successful server-command write. Use a current **server** if you want a `System` line for unknown admin commands (`Unknown command:` plus the token). Older servers sent nothing for that case for admins. |
769+
| 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). |
770+
| 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). |
770771
| 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. |
771772
| 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). |
772773
| 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**). |

TESTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ The Marchat test suite provides foundational coverage of the application's core
3636
| `client/config/interactive_ui_test.go` | Client interactive UI components | TUI forms, profile selection, authentication prompts |
3737
| `client/code_snippet_test.go` | Client code snippet functionality | Text editing, selection, clipboard, syntax highlighting |
3838
| `client/file_picker_test.go` | Client file picker functionality | File browsing, selection, size validation, directory navigation |
39-
| `client/main_test.go` | Client main functionality | Message rendering, user lists, URL handling, encryption functions, flag validation, `wsConnected` transcript reset on reconnect |
39+
| `client/main_test.go` | Client main functionality | Message rendering, user lists, URL handling, encryption functions, flag validation, `wsConnected` transcript reset on reconnect, `TestMessageIncrementsUnread` |
4040
| `client/websocket_sanitize_test.go` | WebSocket URL / TLS hints | Sanitization helpers for display and connection hints |
4141
| `client/exthook/exthook_test.go` | Client hook helpers | Executable validation, hook JSON shaping, path rules |
4242
| `internal/doctor/db_checks_test.go` | Doctor DB probes | SQLite connectivity and version checks used by `-doctor` |

client/main.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,28 @@ func (m *model) shouldNotify(msg shared.Message) (bool, NotificationLevel) {
294294
return true, NotificationLevelInfo
295295
}
296296

297+
// messageIncrementsUnread is true when an inbound message should bump the footer
298+
// unread count while the transcript viewport is not at the bottom. Ephemeral or
299+
// in-place update types (typing, reactions, edits, etc.) must not increment.
300+
func messageIncrementsUnread(m *model, v shared.Message) bool {
301+
if v.Sender == m.cfg.Username {
302+
return false
303+
}
304+
switch v.Type {
305+
case shared.TypingMessage, shared.ReadReceiptType, shared.ReactionMessage,
306+
shared.EditMessageType, shared.DeleteMessage, shared.PinMessage,
307+
shared.SearchMessage, shared.AdminCommandType,
308+
shared.JoinChannelType, shared.LeaveChannelType, shared.ListChannelsType:
309+
return false
310+
case shared.TextMessage, shared.DirectMessage, shared.FileMessageType:
311+
return true
312+
case "":
313+
return true
314+
default:
315+
return false
316+
}
317+
}
318+
297319
type themeStyles struct {
298320
User lipgloss.Style
299321
Time lipgloss.Style
@@ -746,7 +768,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
746768
if wasAtBottom {
747769
m.viewport.GotoBottom()
748770
m.unreadCount = 0
749-
} else {
771+
} else if messageIncrementsUnread(m, v) {
750772
m.unreadCount++
751773
}
752774
m.sending = false
@@ -1925,6 +1947,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
19251947
return m, m.listenWebSocket()
19261948
}
19271949
m.banner = ""
1950+
m.sending = false
19281951
} else if m.useE2E {
19291952
log.Printf("DEBUG: Attempting to send global encrypted message: '%s'", text)
19301953

@@ -1958,6 +1981,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
19581981

19591982
log.Printf("DEBUG: Global encrypted message sent successfully")
19601983
m.banner = ""
1984+
m.sending = false
19611985
} else {
19621986
// Send plain text message
19631987
msg := shared.Message{Sender: m.cfg.Username, Content: text}
@@ -1968,6 +1992,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
19681992
return m, m.listenWebSocket()
19691993
}
19701994
m.banner = ""
1995+
m.sending = false
19711996
}
19721997
}
19731998
m.textarea.SetValue("")

client/main_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,33 @@ import (
1616
"github.com/charmbracelet/bubbles/viewport"
1717
)
1818

19+
func TestMessageIncrementsUnread(t *testing.T) {
20+
m := &model{cfg: config.Config{Username: "me"}}
21+
tests := []struct {
22+
name string
23+
msg shared.Message
24+
want bool
25+
}{
26+
{"own_text", shared.Message{Sender: "me", Type: shared.TextMessage}, false},
27+
{"other_text", shared.Message{Sender: "you", Type: shared.TextMessage}, true},
28+
{"typing", shared.Message{Sender: "you", Type: shared.TypingMessage}, false},
29+
{"reaction", shared.Message{Sender: "you", Type: shared.ReactionMessage}, false},
30+
{"read_receipt", shared.Message{Sender: "you", Type: shared.ReadReceiptType}, false},
31+
{"edit", shared.Message{Sender: "you", Type: shared.EditMessageType}, false},
32+
{"delete", shared.Message{Sender: "you", Type: shared.DeleteMessage}, false},
33+
{"other_dm", shared.Message{Sender: "you", Type: shared.DirectMessage}, true},
34+
{"other_file", shared.Message{Sender: "you", Type: shared.FileMessageType}, true},
35+
{"legacy_empty_type", shared.Message{Sender: "you", Type: ""}, true},
36+
}
37+
for _, tt := range tests {
38+
t.Run(tt.name, func(t *testing.T) {
39+
if got := messageIncrementsUnread(m, tt.msg); got != tt.want {
40+
t.Errorf("got %v want %v", got, tt.want)
41+
}
42+
})
43+
}
44+
}
45+
1946
func TestWsConnectedClearsTranscript(t *testing.T) {
2047
vp := viewport.New(80, 20)
2148
vp.SetContent("stale viewport body")

server/client.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ func (c *Client) readPump() {
8888
if len(msgTimestamps) >= rateLimitMessages {
8989
log.Printf("Rate limit exceeded for client %s, cooldown %v", c.username, rateLimitCooldown)
9090
cooldownUntil = now.Add(rateLimitCooldown)
91+
c.send <- shared.Message{
92+
Sender: "System",
93+
Content: "Rate limited: too many messages in a short window. Wait before sending again.",
94+
CreatedAt: time.Now(),
95+
Type: shared.TextMessage,
96+
}
9197
continue
9298
}
9399
msgTimestamps = append(msgTimestamps, now)

0 commit comments

Comments
 (0)