chat: whisper inbox with :inbox view + ✉ unread chip (UX-12)

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.
This commit is contained in:
m1ngsama 2026-05-17 14:35:16 +08:00
parent ddcecbea81
commit 87d6572156
3 changed files with 92 additions and 9 deletions

View file

@ -7,6 +7,16 @@
#include <libssh/libssh.h>
#include <libssh/server.h>
/* 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;

View file

@ -112,7 +112,8 @@ void commands_dispatch(client_t *client) {
"========================================\n"
"list, users, who - Show online users\n"
"nick/name <name> - Change nickname\n"
"msg/w <user> <text> - Whisper to user\n"
"msg/w <user> <text> - Whisper to user (private)\n"
"inbox - Show whisper history\n"
"last [N] - Show last N messages\n"
"search <keyword> - 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++;

View file

@ -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;