From 87d6572156a0f7740f15c25c3a96b3d9f5596036 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Sun, 17 May 2026 14:35:16 +0800 Subject: [PATCH] =?UTF-8?q?chat:=20whisper=20inbox=20with=20:inbox=20view?= =?UTF-8?q?=20+=20=E2=9C=89=20unread=20chip=20(UX-12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whispers used to flash on the recipient's terminal and disappear with the next redraw. No history, no record, no signal if you weren't looking. Now whispers are stored per-recipient in a bounded inbox (16 slots, FIFO eviction): typedef struct { time_t timestamp; char from[]; char content[]; } whisper_t; whisper_t whisper_inbox[16]; int whisper_inbox_count; _Atomic int unread_whispers; Sender side (:msg / :w): - resolves target as before - pushes the whisper into target->whisper_inbox under target->io_lock (so two simultaneous senders to the same recipient don't tear the ring) - bumps target->unread_whispers atomically - sends a single \a bell + triggers redraw_pending - no longer writes whisper text directly to the channel (which used to get clobbered by the next redraw) Recipient side: - new :inbox command in COMMAND mode prints the snapshot under io_lock, in M7 chat-list style: 悄悄话 · whispers · 3 05-17 13:42 alice: 一会儿要不要喝咖啡 ... - viewing :inbox resets unread_whispers to 0 Title bar (extends UX-11): - bright magenta "✉ N" chip alongside the yellow "★ N" mention chip - same priority / degradation rules as ★ Whispers remain private — they're never broadcast to the room and never persisted to messages.log. The inbox lives only in client_t, so disconnecting drops it. --- include/ssh_server.h | 15 +++++++++++ src/commands.c | 63 +++++++++++++++++++++++++++++++++++++++----- src/tui.c | 23 +++++++++++++--- 3 files changed, 92 insertions(+), 9 deletions(-) diff --git a/include/ssh_server.h b/include/ssh_server.h index 9bb9d0f..8cbafc3 100644 --- a/include/ssh_server.h +++ b/include/ssh_server.h @@ -7,6 +7,16 @@ #include #include +/* One stored whisper. Kept per-recipient, not broadcast to the room + * and not persisted to messages.log. Inbox is bounded; oldest slides + * out FIFO. */ +#define WHISPER_INBOX_SIZE 16 +typedef struct { + time_t timestamp; + char from[MAX_USERNAME_LEN]; + char content[MAX_MESSAGE_LEN]; +} whisper_t; + /* Client connection structure */ typedef struct client { ssh_session session; /* SSH session */ @@ -37,6 +47,11 @@ typedef struct client { time_t last_active; atomic_bool redraw_pending; _Atomic int unread_mentions; /* @-mentions received since last reset */ + _Atomic int unread_whispers; /* whispers received since last :inbox view */ + /* Per-client whisper inbox. Pushes serialise on io_lock; readers are + * the client's own thread inside :inbox handling. */ + whisper_t whisper_inbox[WHISPER_INBOX_SIZE]; + int whisper_inbox_count; bool mute_joins; pthread_t thread; atomic_bool connected; diff --git a/src/commands.c b/src/commands.c index f9b59fc..4200463 100644 --- a/src/commands.c +++ b/src/commands.c @@ -112,7 +112,8 @@ void commands_dispatch(client_t *client) { "========================================\n" "list, users, who - Show online users\n" "nick/name - Change nickname\n" - "msg/w - Whisper to user\n" + "msg/w - Whisper to user (private)\n" + "inbox - Show whisper history\n" "last [N] - Show last N messages\n" "search - Search message history\n" "mute-joins - Toggle join/leave notices\n" @@ -155,12 +156,33 @@ void commands_dispatch(client_t *client) { pthread_rwlock_unlock(&g_room->lock); if (target) { - char whisper[MAX_MESSAGE_LEN]; - snprintf(whisper, sizeof(whisper), - "\r\n\033[35m[whisper from %s]: %s\033[0m\r\n", - client->username, rest); - client_send(target, whisper, strlen(whisper)); + /* Push into recipient's inbox. io_lock serialises so two + * senders to the same recipient don't tear the ring. */ + pthread_mutex_lock(&target->io_lock); + int slot; + if (target->whisper_inbox_count < WHISPER_INBOX_SIZE) { + slot = target->whisper_inbox_count++; + } else { + /* FIFO evict the oldest */ + memmove(&target->whisper_inbox[0], + &target->whisper_inbox[1], + (WHISPER_INBOX_SIZE - 1) * sizeof(whisper_t)); + slot = WHISPER_INBOX_SIZE - 1; + } + target->whisper_inbox[slot].timestamp = time(NULL); + snprintf(target->whisper_inbox[slot].from, + sizeof(target->whisper_inbox[slot].from), + "%s", client->username); + snprintf(target->whisper_inbox[slot].content, + sizeof(target->whisper_inbox[slot].content), + "%s", rest); + pthread_mutex_unlock(&target->io_lock); + + target->unread_whispers++; target->redraw_pending = true; + /* Audible nudge — the title bar ✉ counter (UX-11 style) + * carries the persistent signal. */ + client_send(target, "\a", 1); client_release(target); } @@ -173,6 +195,35 @@ void commands_dispatch(client_t *client) { } } + } else if (strcmp(cmd, "inbox") == 0) { + /* Snapshot the inbox under io_lock so a concurrent sender doesn't + * tear what we're rendering. Counter reset happens after copy. */ + whisper_t snapshot[WHISPER_INBOX_SIZE]; + int snap_count; + pthread_mutex_lock(&client->io_lock); + snap_count = client->whisper_inbox_count; + memcpy(snapshot, client->whisper_inbox, + snap_count * sizeof(whisper_t)); + pthread_mutex_unlock(&client->io_lock); + client->unread_whispers = 0; + + buffer_appendf(output, sizeof(output), &pos, + "\033[1;36m悄悄话 · whispers\033[0m " + "\033[2;37m· %d\033[0m\n", snap_count); + if (snap_count == 0) { + buffer_appendf(output, sizeof(output), &pos, + " \033[2;37m(空)\033[0m\n"); + } + for (int i = 0; i < snap_count; i++) { + char ts[20]; + struct tm tmi; + localtime_r(&snapshot[i].timestamp, &tmi); + strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi); + buffer_appendf(output, sizeof(output), &pos, + " \033[90m%s\033[0m \033[35m%s\033[0m: %s\n", + ts, snapshot[i].from, snapshot[i].content); + } + } else if (strncmp(cmd, "nick ", 5) == 0 || strncmp(cmd, "name ", 5) == 0) { char *new_name = cmd + 5; while (*new_name == ' ') new_name++; diff --git a/src/tui.c b/src/tui.c index a50d3cb..78643f5 100644 --- a/src/tui.c +++ b/src/tui.c @@ -371,11 +371,22 @@ void tui_render_screen(client_t *client) { unread_width = utf8_string_width(unread_buf) + 2; /* leading " · " minus initial space accounted later */ } + /* Unread whispers chip — bright magenta envelope. Same priority as + * the mentions chip; both signal "you missed something". */ + int whisper_count = client->unread_whispers; + char whisper_buf[32] = ""; + int whisper_width = 0; + if (whisper_count > 0) { + snprintf(whisper_buf, sizeof(whisper_buf), "✉ %d", whisper_count); + whisper_width = utf8_string_width(whisper_buf) + 2; + } + /* Decide what fits. Reserve at least 1 col of gap between left and * right halves so they never visually touch. */ int show_hint = 1; int show_mute = client->mute_joins ? 1 : 0; int show_unread = unread_count > 0 ? 1 : 0; + int show_whisper = whisper_count > 0 ? 1 : 0; int show_chips = chip_count; while (show_chips > 1) { @@ -385,16 +396,17 @@ void tui_render_screen(client_t *client) { left_w += utf8_string_width(chips[i].value); } if (show_mute) left_w += mute_width; - if (show_unread) left_w += unread_width + 1; /* + " " separator */ + if (show_unread) left_w += unread_width + 1; + if (show_whisper) left_w += whisper_width + 1; int right_w = (show_hint ? hint_width + 1 /*trailing space*/ : 0); int needed = left_w + 1 /*min gap*/ + right_w; if (needed <= render_width) break; - /* Drop in priority order: hint → mute → mode chip → online count. - * Unread is sticky — only dropped if everything else already is. */ + /* Drop priority: hint → mute → mode → online → whispers → mentions. */ if (show_hint) { show_hint = 0; continue; } if (show_mute) { show_mute = 0; continue; } if (show_chips > 1) { show_chips--; continue; } + if (show_whisper) { show_whisper = 0; continue; } if (show_unread) { show_unread = 0; continue; } break; } @@ -421,6 +433,11 @@ void tui_render_screen(client_t *client) { " \033[1;33m%s\033[0m", unread_buf); left_width += unread_width + 1; } + if (show_whisper) { + buffer_appendf(left, sizeof(left), &lpos, + " \033[1;35m%s\033[0m", whisper_buf); + left_width += whisper_width + 1; + } int gap = render_width - left_width - (show_hint ? hint_width + 2 : 1); if (gap < 1) gap = 1;