diff --git a/Makefile b/Makefile index 03fe85c..390d447 100644 --- a/Makefile +++ b/Makefile @@ -143,6 +143,8 @@ integration-test: all @cd tests && PORT=$$(($${PORT:-2222} + 1)) ./test_exec_mode.sh @cd tests && PORT=$$(($${PORT:-2222} + 2)) ./test_interactive_input.sh @cd tests && PORT=$$(($${PORT:-2222} + 3)) ./test_user_lifecycle.sh + @cd tests && PORT=$$(($${PORT:-2222} + 4)) ./test_mute_joins_view.sh + @cd tests && PORT=$$(($${PORT:-2222} + 5)) ./test_empty_view.sh @cd tests && ./test_tntctl_cli.sh anonymous-access-test: all diff --git a/README.md b/README.md index 52b37e4..6761620 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ past the limit is ignored with a terminal bell. ``` Opens at latest messages Stays pinned to latest until you scroll up -i - Return to INSERT mode +i/a/o - Return to INSERT mode : - Enter COMMAND mode j/k - Scroll down/up one line Ctrl+D/U - Scroll half page down/up @@ -96,7 +96,10 @@ Ctrl+C - Exit chat :nick - Change nickname :msg - Send private message :w - Short alias for :msg +:reply - Reply to latest private message +:r - Short alias for :reply :inbox - Show private messages +:inbox clear - Clear private messages for this session :last [N] - Show last N messages from history (max 50, default 10) :search - Search message history (shows last 15 matches) :mute-joins - Toggle join/leave system notifications @@ -109,8 +112,14 @@ ESC - Return to NORMAL mode ``` Command output pages use `j/k`, `Ctrl+D/U`, and `g/G` for paging. `:inbox` -is live: press `r` to refresh it manually, and it refreshes when a new private -message arrives while the inbox is open. +shows incoming and sent private messages newest-first; press `r` to refresh it +manually, and it refreshes when a new private message arrives while the inbox +is open. `:reply text` and `:r text` send to the latest private-message peer. +Unread incoming private messages are marked with `*` until `:inbox` renders. +The inbox title shows a transient unread count when new private messages are +present. +`:inbox clear` removes private messages and the reply target for this session. +Private messages are per-session only and are not written to `messages.log`. **Special messages (INSERT mode)** ``` @@ -223,7 +232,8 @@ tntctl -l operator chat.example.com post "service notice" ### Log Maintenance Persisted public history is stored as `messages.log` in the TNT state -directory. For manual maintenance, archive and compact it with: +directory. Private messages and local inbox state are intentionally excluded. +For manual maintenance, archive and compact it with: ```sh scripts/logrotate.sh /var/lib/tnt/messages.log 100 10000 diff --git a/docs/EASY_SETUP.md b/docs/EASY_SETUP.md index 9af0b91..c66d589 100644 --- a/docs/EASY_SETUP.md +++ b/docs/EASY_SETUP.md @@ -80,7 +80,9 @@ Common commands: :users online users :nick change nickname :msg send private message +:reply reply to latest private message :inbox show private messages +:inbox clear clear private messages :last [N] recent messages :search search message history :lang en|zh switch UI language diff --git a/docs/INTERFACE.md b/docs/INTERFACE.md index 6351a86..1c7bf34 100644 --- a/docs/INTERFACE.md +++ b/docs/INTERFACE.md @@ -157,6 +157,23 @@ posted In anonymous-access mode, the SSH login name is not authenticated. Operators should configure `TNT_ACCESS_TOKEN` before relying on exec-post identity. +## Interactive Private Messages + +`:msg user message` and its `:w` alias deliver private messages only to online +interactive clients. `:reply message` and its `:r` alias send to the latest +private-message peer in the current session. Private messages are not +persisted to `messages.log` and are not included in exec `tail`, exec `dump`, +`:last`, or `:search`. + +Each participant keeps a bounded in-memory `:inbox` for the current session. +Recipients see incoming private messages; senders see local sent-message +copies. Unread incoming messages are marked with `*` until `:inbox` renders. +`:inbox` displays newest messages first, shows a transient unread count, can +be refreshed with `r`, and refreshes automatically while open when a new +private message arrives. +`:inbox clear` removes the current session's private messages, unread count, +and reply target. + ### `help` Prints a localized human-readable command summary. It is intended for people, diff --git a/docs/MESSAGE_LOG.md b/docs/MESSAGE_LOG.md index 9d5f245..5f243d7 100644 --- a/docs/MESSAGE_LOG.md +++ b/docs/MESSAGE_LOG.md @@ -37,7 +37,10 @@ existing append-only logs remain readable. - `|`, `\n`, and `\r` in content become spaces. - Timestamps are written in UTC. -Private messages are not written to `messages.log`. +Private messages are not written to `messages.log`. `:inbox` stores incoming +and sent private-message copies only in each participant's live session memory, +so inbox state is lost on disconnect and never appears in `tail`, `dump`, +`:last`, or `:search`. ## Replay And Search diff --git a/docs/QUICKREF.md b/docs/QUICKREF.md index 65809b0..88b7ba3 100644 --- a/docs/QUICKREF.md +++ b/docs/QUICKREF.md @@ -30,7 +30,10 @@ COMMANDS (COMMAND mode, prefix with :) nick change nickname msg send private message w alias for msg - inbox show private messages + reply reply to latest private message + r alias for reply + inbox show private messages, newest first + inbox clear clear private messages for this session last [N] last N messages from log (default 10, max 50) search search full history (case-insensitive, 15 results) mute-joins toggle join/leave notifications @@ -45,6 +48,7 @@ INSERT MODE paste multi-line paste stays in the input buffer limit 1023 bytes/message; over-limit input rings bell normal opens/follows latest; k/PgUp older, j/PgDn newer + insert aliases i/a/o enter INSERT mode from NORMAL EXEC COMMANDS health print service health @@ -94,7 +98,7 @@ LIMITS 1024 bytes/message FILES - messages.log chat log (RFC3339) + messages.log public chat log (RFC3339; excludes private messages) host_key SSH key (auto-generated) motd.txt message of the day (optional) CHANGELOG.md version history diff --git a/docs/USER_LIFECYCLE.md b/docs/USER_LIFECYCLE.md index d2ea47e..eec3f63 100644 --- a/docs/USER_LIFECYCLE.md +++ b/docs/USER_LIFECYCLE.md @@ -12,8 +12,8 @@ The product path should stay short: 5. User presses Esc to browse history with Vim-style movement. 6. User uses `:help` for the concise manual or `?` for the full key reference. 7. User searches from NORMAL with `/term`, or uses commands when needed: - `:users`, `:msg`, `:inbox`, `:last`, `:search`, `:nick`, `:mute-joins`, - and `:q`. + `:users`, `:msg`, `:reply`, `:inbox`, `:last`, `:search`, `:nick`, + `:mute-joins`, and `:q`. 8. Scripts and operators use `tntctl` or SSH exec commands for `health`, `stats`, `users`, `tail`, `dump`, and `post`. @@ -32,11 +32,18 @@ The product path should stay short: parallel support commands for the same task. - Command syntax stays ASCII even in localized UI text. Translations explain; they do not change the command language. -- Private messages are visible only in the recipient inbox and are not written - to `messages.log`. +- Private messages are visible in each participant's in-memory `:inbox`: + recipients see incoming messages, senders see local sent-message copies, + newest first. They are not written to `messages.log` and do not survive a + reconnect. - `:inbox` is live enough for normal chat use: it can be refreshed with `r` and refreshes automatically when a new private message arrives while the - inbox is open. + inbox is open. Incoming unread messages are marked with `*` and counted in + the inbox title until the inbox renders them. `:inbox clear` removes private + messages and the reply target for the current session. +- `:reply` / `:r` keeps the private-message path keyboard-short: it answers + the latest private-message peer in the current session without retyping a + username. - Long command output uses a small pager so `:last` and `:search` are readable on small terminals. @@ -47,10 +54,12 @@ The product path should stay short: - second user joins and is visible through `users --json` - first user opens `?`, checks `:users`, sends a public message, scrolls, uses `:last` and `:search` -- first user toggles `:mute-joins`, sends `:msg`, changes nickname, sends - `/me`, and exits -- second user opens `:inbox` before the private message arrives and sees it - auto-refresh after delivery +- first user toggles `:mute-joins`, sends two `:msg` messages, receives a + `:reply`, confirms private-message copies in `:inbox`, clears the inbox, + changes nickname, sends `/me`, and exits +- second user opens `:inbox` before the private messages arrive, sees it + auto-refresh after delivery, newest first, and replies without retyping the + sender's username - exec `tail` sees public messages - `messages.log` contains public history and excludes private-message content diff --git a/include/command_catalog.h b/include/command_catalog.h index c285927..09dcc06 100644 --- a/include/command_catalog.h +++ b/include/command_catalog.h @@ -8,6 +8,7 @@ typedef enum { TNT_COMMAND_HELP, TNT_COMMAND_LANG, TNT_COMMAND_MSG, + TNT_COMMAND_REPLY, TNT_COMMAND_INBOX, TNT_COMMAND_NICK, TNT_COMMAND_LAST, diff --git a/include/i18n.h b/include/i18n.h index fc0bd10..b824867 100644 --- a/include/i18n.h +++ b/include/i18n.h @@ -35,6 +35,8 @@ typedef enum { I18N_TITLE_ONLINE_FORMAT, I18N_TITLE_MUTED, I18N_TITLE_HELP_HINT, + I18N_EMPTY_ROOM, + I18N_EMPTY_FILTERED, I18N_IDLE_TIMEOUT_FORMAT, I18N_SYSTEM_USERNAME, I18N_SYSTEM_JOIN_FORMAT, @@ -43,14 +45,20 @@ typedef enum { I18N_USERS_TITLE, I18N_MSG_SENT_FORMAT, I18N_MSG_USER_NOT_FOUND_FORMAT, + I18N_REPLY_NO_TARGET, I18N_INBOX_TITLE, I18N_INBOX_EMPTY, + I18N_INBOX_SENT_TO_FORMAT, + I18N_INBOX_CLEARED, + I18N_INBOX_UNREAD_FORMAT, I18N_NICK_INVALID, I18N_NICK_TAKEN_FORMAT, I18N_NICK_UNCHANGED, I18N_NICK_CHANGED_FORMAT, I18N_LAST_HEADER_FORMAT, + I18N_LAST_EMPTY, I18N_SEARCH_HEADER_FORMAT, + I18N_SEARCH_EMPTY, I18N_MUTE_JOINS_FORMAT, I18N_MUTE_JOINS_MUTED, I18N_MUTE_JOINS_UNMUTED, diff --git a/include/ssh_server.h b/include/ssh_server.h index 2bcd0e7..bb84c90 100644 --- a/include/ssh_server.h +++ b/include/ssh_server.h @@ -14,7 +14,10 @@ typedef struct { time_t timestamp; char from[MAX_USERNAME_LEN]; + char to[MAX_USERNAME_LEN]; char content[MAX_MESSAGE_LEN]; + bool outgoing; + bool unread; } whisper_t; typedef enum { @@ -59,10 +62,13 @@ typedef struct client { _Atomic int pending_bells; /* Bell nudges for this client's loop */ _Atomic int unread_mentions; /* @-mentions received since last reset */ _Atomic int unread_whispers; /* whispers received since last :inbox view */ + char last_whisper_peer[MAX_USERNAME_LEN]; /* Most recent private-message peer */ char *outbox; /* Bounded queued output for interactive writes */ size_t outbox_len; size_t outbox_pos; size_t outbox_capacity; + char *render_buffer; /* Reused main-screen render buffer */ + size_t render_buffer_capacity; /* Per-client whisper inbox. Protected separately from SSH channel I/O * so slow writes do not block in-memory private-message delivery. */ whisper_t whisper_inbox[WHISPER_INBOX_SIZE]; diff --git a/include/tui.h b/include/tui.h index 7a33207..cbb43b5 100644 --- a/include/tui.h +++ b/include/tui.h @@ -24,6 +24,9 @@ void tui_render_motd(struct client *client); /* Render the input line */ void tui_render_input(struct client *client, const char *input); +/* Render only the command input/status line */ +void tui_render_command_input(struct client *client); + /* Clear the screen */ void tui_clear_screen(struct client *client); diff --git a/src/client.c b/src/client.c index aff4a9e..381bff4 100644 --- a/src/client.c +++ b/src/client.c @@ -237,6 +237,7 @@ void client_release(client_t *client) { free(client->channel_cb); } free(client->outbox); + free(client->render_buffer); pthread_mutex_destroy(&client->io_lock); pthread_mutex_destroy(&client->whisper_lock); pthread_mutex_destroy(&client->ref_lock); diff --git a/src/command_catalog.c b/src/command_catalog.c index f04c375..dafd615 100644 --- a/src/command_catalog.c +++ b/src/command_catalog.c @@ -36,13 +36,25 @@ static const command_catalog_entry_t entries[] = { " w \n"), 2, false, true }, + { + {TNT_COMMAND_REPLY, "reply", {"reply", "r", NULL}}, + I18N_STRING(":reply , :r ", + ":reply , :r "), + I18N_STRING("Reply to latest private message", "回复最近私信"), + I18N_STRING(":reply ", ":reply "), + I18N_STRING("Usage: reply \n" + " r \n", + "用法: reply \n" + " r \n"), + 2, false, true + }, { {TNT_COMMAND_INBOX, "inbox", {"inbox", NULL}}, + I18N_STRING(":inbox, :inbox clear", ":inbox, :inbox clear"), + I18N_STRING("Show or clear private messages", "查看或清空私信"), I18N_STRING(":inbox", ":inbox"), - I18N_STRING("Show private messages", "查看私信"), - I18N_STRING(":inbox", ":inbox"), - I18N_STRING("Usage: inbox\n", "用法: inbox\n"), - 2, true, false + I18N_STRING("Usage: inbox [clear]\n", "用法: inbox [clear]\n"), + 2, false, false }, { {TNT_COMMAND_NICK, "nick", {"nick", "name", NULL}}, @@ -227,6 +239,9 @@ bool command_catalog_args_valid(tnt_command_id_t id, const char *args) { if (!entry) { return false; } + if (id == TNT_COMMAND_INBOX) { + return !args || args[0] == '\0' || strcmp(args, "clear") == 0; + } if (entry->no_args) { return !args || args[0] == '\0'; } diff --git a/src/commands.c b/src/commands.c index 9e53de2..61f04be 100644 --- a/src/commands.c +++ b/src/commands.c @@ -52,38 +52,153 @@ static void append_command_usage(char *output, size_t buf_size, size_t *pos, command_catalog_append_usage(output, buf_size, pos, id, lang); } +static bool message_visible_for_client(const client_t *client, + const message_t *msg) { + return !client || !client->mute_joins || + !system_message_is_join_leave(msg); +} + +static void client_append_whisper(client_t *owner, const char *from, + const char *to, const char *content, + bool outgoing, bool count_unread) { + if (!owner || !from || !to || !content) return; + + pthread_mutex_lock(&owner->whisper_lock); + int slot; + if (owner->whisper_inbox_count < WHISPER_INBOX_SIZE) { + slot = owner->whisper_inbox_count++; + } else { + memmove(&owner->whisper_inbox[0], + &owner->whisper_inbox[1], + (WHISPER_INBOX_SIZE - 1) * sizeof(whisper_t)); + slot = WHISPER_INBOX_SIZE - 1; + } + + owner->whisper_inbox[slot].timestamp = time(NULL); + snprintf(owner->whisper_inbox[slot].from, + sizeof(owner->whisper_inbox[slot].from), "%s", from); + snprintf(owner->whisper_inbox[slot].to, + sizeof(owner->whisper_inbox[slot].to), "%s", to); + snprintf(owner->whisper_inbox[slot].content, + sizeof(owner->whisper_inbox[slot].content), "%s", content); + owner->whisper_inbox[slot].outgoing = outgoing; + owner->whisper_inbox[slot].unread = count_unread; + snprintf(owner->last_whisper_peer, sizeof(owner->last_whisper_peer), "%s", + outgoing ? to : from); + if (count_unread) { + owner->unread_whispers++; + } + pthread_mutex_unlock(&owner->whisper_lock); +} + +static void send_private_message(client_t *client, const char *target_name, + const char *content, char *output, + size_t buf_size, size_t *pos) { + bool found = false; + client_t *target = NULL; + + pthread_rwlock_rdlock(&g_room->lock); + for (int i = 0; i < g_room->client_count; i++) { + if (strcmp(g_room->clients[i]->username, target_name) == 0) { + target = g_room->clients[i]; + client_addref(target); + found = true; + break; + } + } + pthread_rwlock_unlock(&g_room->lock); + + if (target) { + client_append_whisper(target, client->username, target_name, + content, false, true); + if (target != client) { + client_append_whisper(client, client->username, target_name, + content, true, false); + } + + /* Audible nudge: the title bar whisper counter carries the + * persistent signal without cross-client SSH writes. */ + client_queue_bell(target); + client_release(target); + } + + if (found) { + buffer_appendf(output, buf_size, pos, + i18n_text(client->ui_lang, I18N_MSG_SENT_FORMAT), + target_name); + } else { + buffer_appendf(output, buf_size, pos, + i18n_text(client->ui_lang, + I18N_MSG_USER_NOT_FOUND_FORMAT), + target_name); + } +} + static void append_inbox_output(client_t *client, char *output, size_t buf_size, size_t *pos) { whisper_t snapshot[WHISPER_INBOX_SIZE]; int snap_count; + int unread_count; pthread_mutex_lock(&client->whisper_lock); snap_count = client->whisper_inbox_count; + unread_count = client->unread_whispers; memcpy(snapshot, client->whisper_inbox, snap_count * sizeof(whisper_t)); + for (int i = 0; i < snap_count; i++) { + client->whisper_inbox[i].unread = false; + } client->unread_whispers = 0; pthread_mutex_unlock(&client->whisper_lock); buffer_appendf(output, buf_size, pos, - "\033[1;36m%s\033[0m \033[2;37m· %d\033[0m\n", + "\033[1;36m%s\033[0m \033[2;37m· %d", i18n_text(client->ui_lang, I18N_INBOX_TITLE), snap_count); + if (unread_count > 0) { + buffer_appendf(output, buf_size, pos, + " · "); + buffer_appendf(output, buf_size, pos, + i18n_text(client->ui_lang, + I18N_INBOX_UNREAD_FORMAT), + unread_count); + } + buffer_appendf(output, buf_size, pos, "\033[0m\n"); if (snap_count == 0) { buffer_appendf(output, buf_size, pos, " \033[2;37m%s\033[0m\n", i18n_text(client->ui_lang, I18N_INBOX_EMPTY)); } - for (int i = 0; i < snap_count; i++) { + for (int i = snap_count - 1; i >= 0; i--) { char ts[20]; + char peer[MAX_USERNAME_LEN + 16]; + const char *marker = snapshot[i].unread ? "\033[1;35m*\033[0m" : " "; struct tm tmi; localtime_r(&snapshot[i].timestamp, &tmi); strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi); + if (snapshot[i].outgoing) { + snprintf(peer, sizeof(peer), + i18n_text(client->ui_lang, + I18N_INBOX_SENT_TO_FORMAT), + snapshot[i].to); + } else { + snprintf(peer, sizeof(peer), "%s", snapshot[i].from); + } buffer_appendf(output, buf_size, pos, - " \033[90m%s\033[0m \033[35m%s\033[0m: %s\n", - ts, snapshot[i].from, snapshot[i].content); + " %s \033[90m%s\033[0m \033[35m%s\033[0m: %s\n", + marker, ts, peer, snapshot[i].content); } } +static void clear_inbox(client_t *client) { + pthread_mutex_lock(&client->whisper_lock); + memset(client->whisper_inbox, 0, sizeof(client->whisper_inbox)); + client->whisper_inbox_count = 0; + client->unread_whispers = 0; + client->last_whisper_peer[0] = '\0'; + pthread_mutex_unlock(&client->whisper_lock); +} + bool commands_refresh_active_output(client_t *client) { char output[MAX_COMMAND_OUTPUT_LEN] = {0}; size_t pos = 0; @@ -237,65 +352,49 @@ void commands_dispatch(client_t *client) { append_command_usage(output, sizeof(output), &pos, TNT_COMMAND_MSG, client->ui_lang); } else { - bool found = false; - client_t *target = NULL; - pthread_rwlock_rdlock(&g_room->lock); - for (int i = 0; i < g_room->client_count; i++) { - if (strcmp(g_room->clients[i]->username, target_name) == 0) { - target = g_room->clients[i]; - client_addref(target); - found = true; - break; - } - } - pthread_rwlock_unlock(&g_room->lock); + send_private_message(client, target_name, rest, output, + sizeof(output), &pos); + } - if (target) { - /* Push into recipient's inbox. whisper_lock serialises so - * two senders to the same recipient don't tear the ring. */ - pthread_mutex_lock(&target->whisper_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); - target->unread_whispers++; - pthread_mutex_unlock(&target->whisper_lock); + } else if (command_id == TNT_COMMAND_REPLY) { + const char *message = arg; + char target_name[MAX_USERNAME_LEN] = {0}; - /* Audible nudge — the title bar ✉ counter (UX-11 style) - * carries the persistent signal. */ - client_queue_bell(target); - client_release(target); - } + while (*message == ' ') message++; + if (message[0] == '\0') { + append_command_usage(output, sizeof(output), &pos, + TNT_COMMAND_REPLY, client->ui_lang); + } else { + pthread_mutex_lock(&client->whisper_lock); + snprintf(target_name, sizeof(target_name), "%s", + client->last_whisper_peer); + pthread_mutex_unlock(&client->whisper_lock); - if (found) { - buffer_appendf(output, sizeof(output), &pos, + if (target_name[0] == '\0') { + buffer_appendf(output, sizeof(output), &pos, "%s", i18n_text(client->ui_lang, - I18N_MSG_SENT_FORMAT), - target_name); + I18N_REPLY_NO_TARGET)); } else { - buffer_appendf(output, sizeof(output), &pos, - i18n_text(client->ui_lang, - I18N_MSG_USER_NOT_FOUND_FORMAT), - target_name); + send_private_message(client, target_name, message, output, + sizeof(output), &pos); } } } else if (command_id == TNT_COMMAND_INBOX) { - output_kind = TNT_COMMAND_OUTPUT_INBOX; - append_inbox_output(client, output, sizeof(output), &pos); + const char *inbox_arg = arg; + while (*inbox_arg == ' ') inbox_arg++; + if (strcmp(inbox_arg, "clear") == 0) { + clear_inbox(client); + output_kind = TNT_COMMAND_OUTPUT_INBOX; + buffer_appendf(output, sizeof(output), &pos, "%s", + i18n_text(client->ui_lang, + I18N_INBOX_CLEARED)); + buffer_appendf(output, sizeof(output), &pos, "\n"); + append_inbox_output(client, output, sizeof(output), &pos); + } else { + output_kind = TNT_COMMAND_OUTPUT_INBOX; + append_inbox_output(client, output, sizeof(output), &pos); + } } else if (command_id == TNT_COMMAND_NICK) { const char *new_name = arg; @@ -374,17 +473,31 @@ void commands_dispatch(client_t *client) { } message_t *last_msgs = NULL; - int last_count = message_load(&last_msgs, n); + int load_count = message_load(&last_msgs, + client->mute_joins ? MAX_MESSAGES : n); + int visible_count = 0; + for (int i = 0; i < load_count; i++) { + if (message_visible_for_client(client, &last_msgs[i])) { + last_msgs[visible_count++] = last_msgs[i]; + } + } + int start = visible_count > n ? visible_count - n : 0; + int last_count = visible_count - start; buffer_appendf(output, sizeof(output), &pos, i18n_text(client->ui_lang, I18N_LAST_HEADER_FORMAT), last_count); + if (last_count == 0) { + buffer_appendf(output, sizeof(output), &pos, "%s", + i18n_text(client->ui_lang, I18N_LAST_EMPTY)); + } for (int i = 0; i < last_count; i++) { + message_t *msg = &last_msgs[start + i]; char ts[20]; struct tm tmi; - localtime_r(&last_msgs[i].timestamp, &tmi); + localtime_r(&msg->timestamp, &tmi); strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi); buffer_appendf(output, sizeof(output), &pos, - "[%s] %s: %s\n", ts, last_msgs[i].username, last_msgs[i].content); + "[%s] %s: %s\n", ts, msg->username, msg->content); } free(last_msgs); @@ -396,23 +509,38 @@ void commands_dispatch(client_t *client) { TNT_COMMAND_SEARCH, client->ui_lang); } else { message_t *found = NULL; - int found_count = message_search(query, &found, 15); + int search_limit = client->mute_joins ? MAX_MESSAGES : 15; + int found_count = message_search(query, &found, search_limit); + int visible_count = 0; + for (int i = 0; i < found_count; i++) { + if (message_visible_for_client(client, &found[i])) { + found[visible_count++] = found[i]; + } + } + int start = visible_count > 15 ? visible_count - 15 : 0; + int display_count = visible_count - start; buffer_appendf(output, sizeof(output), &pos, i18n_text(client->ui_lang, I18N_SEARCH_HEADER_FORMAT), - query, found_count); - for (int i = 0; i < found_count; i++) { + query, display_count); + if (display_count == 0) { + buffer_appendf(output, sizeof(output), &pos, "%s", + i18n_text(client->ui_lang, + I18N_SEARCH_EMPTY)); + } + for (int i = 0; i < display_count; i++) { + message_t *msg = &found[start + i]; char ts[20]; struct tm tmi; - localtime_r(&found[i].timestamp, &tmi); + localtime_r(&msg->timestamp, &tmi); strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi); buffer_appendf(output, sizeof(output), &pos, "[%s] ", ts); append_highlighted(output, sizeof(output), &pos, - found[i].username, query); + msg->username, query); buffer_appendf(output, sizeof(output), &pos, ": "); append_highlighted(output, sizeof(output), &pos, - found[i].content, query); + msg->content, query); buffer_appendf(output, sizeof(output), &pos, "\n"); } free(found); diff --git a/src/help_text.c b/src/help_text.c index 9c10e0d..637881c 100644 --- a/src/help_text.c +++ b/src/help_text.c @@ -26,7 +26,7 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos, "NORMAL MODE KEYS:\n" " Opens at latest messages\n" " Follows latest until you scroll up\n" - " i - Return to INSERT mode\n" + " i/a/o - Return to INSERT mode\n" " : - Enter COMMAND mode\n" " / - Search message history\n" " j/k - Scroll down/up one line\n" @@ -59,7 +59,7 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos, "NORMAL 模式按键:\n" " 默认停在最新消息\n" " 未向上翻阅时自动跟随最新消息\n" - " i - 返回 INSERT 模式\n" + " i/a/o - 返回 INSERT 模式\n" " : - 进入 COMMAND 模式\n" " / - 搜索消息历史\n" " j/k - 向下/上滚动一行\n" diff --git a/src/i18n_text.c b/src/i18n_text.c index 5f009e4..3795839 100644 --- a/src/i18n_text.c +++ b/src/i18n_text.c @@ -81,6 +81,14 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = { "? keys", "? 按键" ), + [I18N_EMPTY_ROOM] = I18N_STRING( + "No messages yet", + "暂无消息" + ), + [I18N_EMPTY_FILTERED] = I18N_STRING( + "No visible messages", + "暂无可见消息" + ), [I18N_IDLE_TIMEOUT_FORMAT] = I18N_STRING( "\r\n\033[33mDisconnected: idle timeout (%d min)\033[0m\r\n", "\r\n\033[33m已断开: 空闲超时 (%d 分钟)\033[0m\r\n" @@ -113,6 +121,10 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = { "User '%s' not found\n", "未找到用户 '%s'\n" ), + [I18N_REPLY_NO_TARGET] = I18N_STRING( + "No private message to reply to\n", + "没有可回复的私信\n" + ), [I18N_INBOX_TITLE] = I18N_STRING( "Private messages", "私信" @@ -121,6 +133,18 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = { "(empty)", "(空)" ), + [I18N_INBOX_SENT_TO_FORMAT] = I18N_STRING( + "you -> %s", + "你 -> %s" + ), + [I18N_INBOX_CLEARED] = I18N_STRING( + "Private messages cleared\n", + "私信已清空\n" + ), + [I18N_INBOX_UNREAD_FORMAT] = I18N_STRING( + "%d new", + "%d 新" + ), [I18N_NICK_INVALID] = I18N_STRING( "Invalid username\n", "用户名无效\n" @@ -141,10 +165,18 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = { "--- Last %d message(s) ---\n", "--- 最近 %d 条消息 ---\n" ), + [I18N_LAST_EMPTY] = I18N_STRING( + "No messages to show\n", + "没有可显示的消息\n" + ), [I18N_SEARCH_HEADER_FORMAT] = I18N_STRING( "--- Search: \"%s\" (showing last %d match(es)) ---\n", "--- 搜索: \"%s\" (显示最近 %d 条匹配) ---\n" ), + [I18N_SEARCH_EMPTY] = I18N_STRING( + "No matches\n", + "没有匹配结果\n" + ), [I18N_MUTE_JOINS_FORMAT] = I18N_STRING( "Join/leave notifications: %s\n", "加入/离开提示: %s\n" diff --git a/src/input.c b/src/input.c index a19811b..640c6c5 100644 --- a/src/input.c +++ b/src/input.c @@ -24,6 +24,8 @@ static int g_idle_timeout = TNT_DEFAULT_IDLE_TIMEOUT; static ui_lang_t g_default_ui_lang = UI_LANG_EN; +#define MAIN_LOOP_POLL_TIMEOUT_MS 250 + void input_init(void) { g_idle_timeout = tnt_config_env_int(&TNT_CONFIG_IDLE_TIMEOUT); g_default_ui_lang = i18n_default_ui_lang(); @@ -212,20 +214,44 @@ static bool append_paste_byte(char *input, unsigned char b) { return false; } +static int normal_visible_message_count(const client_t *client) { + if (!client || !client->mute_joins) { + return room_get_message_count(g_room); + } + + int count = 0; + pthread_rwlock_rdlock(&g_room->lock); + for (int i = 0; i < g_room->message_count; i++) { + if (!system_message_is_join_leave(&g_room->messages[i])) { + count++; + } + } + pthread_rwlock_unlock(&g_room->lock); + return count; +} + static void normal_scroll_to_latest(client_t *client) { if (!client) return; history_view_scroll_to_latest(&client->scroll_pos, &client->follow_tail, - room_get_message_count(g_room), + normal_visible_message_count(client), history_view_height(client->height)); } static void normal_scroll_by(client_t *client, int delta) { if (!client) return; history_view_scroll_by(&client->scroll_pos, &client->follow_tail, - room_get_message_count(g_room), + normal_visible_message_count(client), history_view_height(client->height), delta); } +static void normal_enter_insert(client_t *client) { + if (!client) return; + client->mode = MODE_INSERT; + client->follow_tail = true; + client->unread_mentions = 0; + tui_render_screen(client); +} + static void dismiss_command_output(client_t *client) { bool was_motd; @@ -629,22 +655,20 @@ static bool handle_key(client_t *client, unsigned char key, char *input) { case MODE_NORMAL: { int nm_msg_height = history_view_height(client->height); - if (key == 'i') { - client->mode = MODE_INSERT; - client->follow_tail = true; - client->unread_mentions = 0; - tui_render_screen(client); + if (key == 'i' || key == 'a' || key == 'A' || + key == 'o' || key == 'O') { + normal_enter_insert(client); return true; } else if (key == ':') { client->mode = MODE_COMMAND; client->command_input[0] = '\0'; - tui_render_screen(client); + tui_render_command_input(client); return true; } else if (key == '/') { client->mode = MODE_COMMAND; snprintf(client->command_input, sizeof(client->command_input), "search "); - tui_render_screen(client); + tui_render_command_input(client); return true; } else if (key == 'j') { normal_scroll_by(client, 1); @@ -744,7 +768,7 @@ static bool handle_key(client_t *client, unsigned char key, char *input) { client->command_history[client->command_history_pos], sizeof(client->command_input) - 1); client->command_input[sizeof(client->command_input) - 1] = '\0'; - tui_render_screen(client); + tui_render_command_input(client); } return true; } else if (seq[1] == 'B') { /* Down arrow */ @@ -758,7 +782,7 @@ static bool handle_key(client_t *client, unsigned char key, char *input) { client->command_history_pos = client->command_history_count; client->command_input[0] = '\0'; } - tui_render_screen(client); + tui_render_command_input(client); return true; } } @@ -773,19 +797,19 @@ static bool handle_key(client_t *client, unsigned char key, char *input) { } else if (key == 127 || key == 8) { /* Backspace */ if (client->command_input[0] != '\0') { utf8_remove_last_char(client->command_input); - tui_render_screen(client); + tui_render_command_input(client); } return true; /* Key consumed */ } else if (key == 23) { /* Ctrl+W (Delete Word) */ if (client->command_input[0] != '\0') { utf8_remove_last_word(client->command_input); - tui_render_screen(client); + tui_render_command_input(client); } return true; } else if (key == 21) { /* Ctrl+U (Delete Line) */ if (client->command_input[0] != '\0') { client->command_input[0] = '\0'; - tui_render_screen(client); + tui_render_command_input(client); } return true; } @@ -892,7 +916,8 @@ main_loop: break; } - int ready = ssh_channel_poll_timeout(client->channel, 1000, 0); + int ready = ssh_channel_poll_timeout(client->channel, + MAIN_LOOP_POLL_TIMEOUT_MS, 0); if (ready == SSH_ERROR) { break; @@ -1029,7 +1054,7 @@ main_loop: if (len < sizeof(client->command_input) - 1) { client->command_input[len] = b; client->command_input[len + 1] = '\0'; - tui_render_screen(client); + tui_render_command_input(client); } else { client_send(client, "\a", 1); } @@ -1047,7 +1072,7 @@ main_loop: if (len + (size_t)char_len <= sizeof(client->command_input) - 1) { memcpy(client->command_input + len, buf, char_len); client->command_input[len + char_len] = '\0'; - tui_render_screen(client); + tui_render_command_input(client); } else { client_send(client, "\a", 1); } diff --git a/src/manual_text.c b/src/manual_text.c index f7badaf..54834e0 100644 --- a/src/manual_text.c +++ b/src/manual_text.c @@ -13,7 +13,7 @@ void manual_text_append_interactive(char *buffer, size_t buf_size, "\n" "\033[1;37mUse\033[0m\n" " Type, Enter sends; Up/Down recalls; Tab completes @mentions\n" - " Esc browses; / searches; G latest; i types; : commands; ? keys\n" + " Esc browses; / searches; G latest; i/a/o types; : commands; ? keys\n" "\n" "\033[1;37mCommands\033[0m\n", "\033[1;36mTNT(1) 帮助\033[0m\n" @@ -23,7 +23,7 @@ void manual_text_append_interactive(char *buffer, size_t buf_size, "\n" "\033[1;37m使用\033[0m\n" " 输入并 Enter 发送;Up/Down 调出消息;Tab 补全 @mention\n" - " Esc 浏览;/ 搜索;G 最新;i 输入;: 命令;? 按键\n" + " Esc 浏览;/ 搜索;G 最新;i/a/o 输入;: 命令;? 按键\n" "\n" "\033[1;37m命令\033[0m\n" ); diff --git a/src/tui.c b/src/tui.c index f671b70..31058f0 100644 --- a/src/tui.c +++ b/src/tui.c @@ -21,6 +21,25 @@ static const char *username_color(const char *name) { return colors[h % 6]; } +static char *client_render_buffer(client_t *client, size_t min_size) { + if (!client || min_size == 0) { + return NULL; + } + + if (client->render_buffer_capacity >= min_size) { + return client->render_buffer; + } + + char *grown = realloc(client->render_buffer, min_size); + if (!grown) { + return NULL; + } + + client->render_buffer = grown; + client->render_buffer_capacity = min_size; + return client->render_buffer; +} + static void format_message_colored(const message_t *msg, char *buffer, size_t buf_size, int width, const char *my_username) { @@ -245,7 +264,7 @@ void tui_render_screen(client_t *client) { if (render_height < 4) render_height = 4; const size_t buf_size = (size_t)(render_height + 10) * (MAX_MESSAGE_LEN + 64) + 2048; - char *buffer = malloc(buf_size); + char *buffer = client_render_buffer(client, buf_size); if (!buffer) return; size_t pos = 0; buffer[0] = '\0'; @@ -255,6 +274,7 @@ void tui_render_screen(client_t *client) { int online = g_room->client_count; int msg_count = g_room->message_count; pthread_rwlock_unlock(&g_room->lock); + int raw_msg_count = msg_count; /* Calculate which messages to show. The initial slice is capped by * message count; the lock-held copy below tightens "latest" slices so @@ -280,47 +300,95 @@ void tui_render_screen(client_t *client) { int end = start + msg_height; if (end > msg_count) end = msg_count; - /* Allocate snapshot outside the lock to avoid blocking writers */ + message_t *visible_messages = NULL; message_t *msg_snapshot = NULL; - int snapshot_capacity = msg_height; - int snapshot_count = end - start; + int snapshot_count = 0; - if (snapshot_count > 0 && snapshot_capacity > 0) { - msg_snapshot = calloc(snapshot_capacity, sizeof(message_t)); + if (client->mute_joins && msg_count > 0) { + visible_messages = calloc(MAX_MESSAGES, sizeof(message_t)); + if (visible_messages) { + int visible_count = 0; + + pthread_rwlock_rdlock(&g_room->lock); + online = g_room->client_count; + raw_msg_count = g_room->message_count; + for (int i = 0; i < g_room->message_count; i++) { + if (!system_message_is_join_leave(&g_room->messages[i])) { + visible_messages[visible_count++] = g_room->messages[i]; + } + } + pthread_rwlock_unlock(&g_room->lock); + + msg_count = visible_count; + latest_scroll_start = history_view_max_scroll(msg_count, msg_height); + anchor_latest = client->mode != MODE_NORMAL || + client->follow_tail || + client->scroll_pos >= latest_scroll_start; + if (client->mode == MODE_NORMAL) { + start = client->scroll_pos; + if (start > latest_scroll_start) { + start = latest_scroll_start; + } + if (start < 0) start = 0; + } else { + start = latest_scroll_start; + } + end = start + msg_height; + if (end > msg_count) end = msg_count; + if (anchor_latest) { + start = history_view_latest_start_for_height( + visible_messages, msg_count, msg_height); + end = msg_count; + } + snapshot_count = end - start; + if (snapshot_count > 0) { + msg_snapshot = visible_messages + start; + } + } } - /* Second pass under lock: copy messages */ - if (msg_snapshot) { - pthread_rwlock_rdlock(&g_room->lock); - /* Re-clamp in case msg_count changed */ - int actual_count = g_room->message_count; - int actual_start = start; - int actual_end = end; - if (anchor_latest) { - actual_end = actual_count; - actual_start = history_view_latest_start_for_height( - g_room->messages, actual_count, msg_height); - } else { - actual_end = (actual_end <= actual_count) ? actual_end : actual_count; - actual_start = (actual_start < actual_end) ? actual_start : actual_end; + if (!visible_messages) { + /* Allocate snapshot outside the lock to avoid blocking writers */ + int snapshot_capacity = msg_height; + snapshot_count = end - start; + + if (snapshot_count > 0 && snapshot_capacity > 0) { + msg_snapshot = calloc(snapshot_capacity, sizeof(message_t)); } - int actual_snapshot = actual_end - actual_start; - if (actual_snapshot > 0 && actual_snapshot <= snapshot_capacity) { - memcpy(msg_snapshot, &g_room->messages[actual_start], - actual_snapshot * sizeof(message_t)); - start = actual_start; - end = actual_end; - snapshot_count = actual_snapshot; - } else { - snapshot_count = 0; + + /* Second pass under lock: copy messages */ + if (msg_snapshot) { + pthread_rwlock_rdlock(&g_room->lock); + /* Re-clamp in case msg_count changed */ + int actual_count = g_room->message_count; + int actual_start = start; + int actual_end = end; + if (anchor_latest) { + actual_end = actual_count; + actual_start = history_view_latest_start_for_height( + g_room->messages, actual_count, msg_height); + } else { + actual_end = (actual_end <= actual_count) ? actual_end : actual_count; + actual_start = (actual_start < actual_end) ? actual_start : actual_end; + } + int actual_snapshot = actual_end - actual_start; + if (actual_snapshot > 0 && actual_snapshot <= snapshot_capacity) { + memcpy(msg_snapshot, &g_room->messages[actual_start], + actual_snapshot * sizeof(message_t)); + start = actual_start; + end = actual_end; + snapshot_count = actual_snapshot; + } else { + snapshot_count = 0; + } + pthread_rwlock_unlock(&g_room->lock); } - pthread_rwlock_unlock(&g_room->lock); } /* Now render using snapshot (no lock held) */ /* If mute_joins is set, remove join/leave messages from snapshot in place */ - if (client->mute_joins && msg_snapshot) { + if (client->mute_joins && msg_snapshot && !anchor_latest) { int filtered = 0; for (int i = 0; i < snapshot_count; i++) { if (!system_message_is_join_leave(&msg_snapshot[i])) { @@ -513,9 +581,26 @@ void tui_render_screen(client_t *client) { buffer_appendf(buffer, buf_size, &pos, "%s\033[K\r\n", msg_line); rows_written++; } - free(msg_snapshot); } + if (rows_written == 0) { + const char *empty_text = + client->mute_joins && raw_msg_count > 0 + ? i18n_text(client->ui_lang, I18N_EMPTY_FILTERED) + : i18n_text(client->ui_lang, I18N_EMPTY_ROOM); + int empty_width = utf8_string_width(empty_text); + int empty_pad = (render_width - empty_width) / 2; + if (empty_pad < 0) empty_pad = 0; + for (int i = 0; i < empty_pad; i++) { + buffer_append_bytes(buffer, buf_size, &pos, " ", 1); + } + buffer_appendf(buffer, buf_size, &pos, + "\033[2;37m%s\033[0m\033[K\r\n", empty_text); + rows_written++; + } + + free(visible_messages ? visible_messages : msg_snapshot); + /* Fill empty lines and clear them */ for (int i = rows_written; i < msg_height; i++) { buffer_appendf(buffer, buf_size, &pos, "\033[K\r\n"); @@ -531,7 +616,6 @@ void tui_render_screen(client_t *client) { tui_status_append(buffer, buf_size, &pos, client, msg_count, start, end); client_send(client, buffer, pos); - free(buffer); } /* Render the input line. @@ -608,6 +692,23 @@ void tui_render_input(client_t *client, const char *input) { client_send(client, buffer, strlen(buffer)); } +void tui_render_command_input(client_t *client) { + if (!client || !client->connected) return; + + int rh = client->height; + if (rh < 4) rh = 4; + + char buffer[sizeof(client->command_input) + 64]; + size_t pos = 0; + buffer[0] = '\0'; + + buffer_appendf(buffer, sizeof(buffer), &pos, + "\033[%d;1H" ANSI_CLEAR_LINE, rh); + tui_status_append(buffer, sizeof(buffer), &pos, client, 0, 0, 0); + + client_send(client, buffer, pos); +} + /* Render the command output screen */ void tui_render_command_output(client_t *client) { if (!client || !client->connected) return; diff --git a/tests/test_empty_view.sh b/tests/test_empty_view.sh new file mode 100755 index 0000000..a247d65 --- /dev/null +++ b/tests/test_empty_view.sh @@ -0,0 +1,109 @@ +#!/bin/sh +# Regression test for the empty/filtered-empty main view. + +PORT=${PORT:-12350} +PASS=0 +FAIL=0 +BIN="../tnt" +SERVER_PID="" +STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-empty-view-test.XXXXXX") + +cleanup() { + if [ -n "$SERVER_PID" ]; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + rm -rf "$STATE_DIR" +} + +trap cleanup EXIT + +if ! command -v expect >/dev/null 2>&1; then + echo "expect not installed; skipping empty view test" + exit 0 +fi + +if [ ! -f "$BIN" ]; then + echo "Error: Binary $BIN not found. Run make first." + exit 1 +fi + +SSH_OPTS="-e none -tt -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p $PORT" + +echo "=== TNT Empty View Test ===" + +TNT_LANG=en TNT_RATE_LIMIT=0 "$BIN" --bind 127.0.0.1 \ + -p "$PORT" -d "$STATE_DIR" >"$STATE_DIR/server.log" 2>&1 & +SERVER_PID=$! + +SERVER_READY=0 +for _ in 1 2 3 4 5; do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "x Server failed to start" + sed -n '1,120p' "$STATE_DIR/server.log" + exit 1 + fi + if grep -q "TNT chat server listening" "$STATE_DIR/server.log"; then + SERVER_READY=1 + break + fi + sleep 1 +done + +if [ "$SERVER_READY" -eq 1 ]; then + echo "✓ server started" + PASS=$((PASS + 1)) +else + echo "x Server did not become ready" + sed -n '1,120p' "$STATE_DIR/server.log" + exit 1 +fi + +VIEW_SCRIPT="$STATE_DIR/empty-view.expect" +cat >"$VIEW_SCRIPT" <"$STATE_DIR/empty-view.log" 2>&1; then + echo "✓ filtered-empty main view shows a state hint" + PASS=$((PASS + 1)) +else + echo "x filtered-empty main view did not show state hint" + sed -n '1,220p' "$STATE_DIR/empty-view.log" + sed -n '1,120p' "$STATE_DIR/server.log" + FAIL=$((FAIL + 1)) +fi + +echo "" +echo "PASSED: $PASS" +echo "FAILED: $FAIL" +[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed" +exit "$FAIL" diff --git a/tests/test_interactive_input.sh b/tests/test_interactive_input.sh index f02e107..cee8813 100755 --- a/tests/test_interactive_input.sh +++ b/tests/test_interactive_input.sh @@ -591,6 +591,38 @@ else FAIL=$((FAIL + 1)) fi +VIM_INSERT_ALIASES_SCRIPT="$STATE_DIR/vim-insert-aliases.expect" +cat >"$VIM_INSERT_ALIASES_SCRIPT" <"$STATE_DIR/vim-insert-aliases.log" 2>&1; then + echo "✓ Vim insert aliases enter INSERT mode" + PASS=$((PASS + 1)) +else + echo "x Vim insert aliases failed" + sed -n '1,200p' "$STATE_DIR/vim-insert-aliases.log" + sed -n '1,120p' "$STATE_DIR/server.log" + FAIL=$((FAIL + 1)) +fi + echo "" echo "PASSED: $PASS" echo "FAILED: $FAIL" diff --git a/tests/test_mute_joins_view.sh b/tests/test_mute_joins_view.sh new file mode 100755 index 0000000..91c0277 --- /dev/null +++ b/tests/test_mute_joins_view.sh @@ -0,0 +1,132 @@ +#!/bin/sh +# Regression test for :mute-joins filling the latest view with real messages. + +PORT=${PORT:-12349} +PASS=0 +FAIL=0 +BIN="../tnt" +SERVER_PID="" +STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-mute-joins-test.XXXXXX") + +cleanup() { + if [ -n "$SERVER_PID" ]; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + rm -rf "$STATE_DIR" +} + +trap cleanup EXIT + +if ! command -v expect >/dev/null 2>&1; then + echo "expect not installed; skipping mute-joins view test" + exit 0 +fi + +if [ ! -f "$BIN" ]; then + echo "Error: Binary $BIN not found. Run make first." + exit 1 +fi + +SSH_OPTS="-e none -tt -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p $PORT" + +echo "=== TNT Mute Joins View Test ===" + +seed_ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ') +i=1 +while [ "$i" -le 18 ]; do + printf '%s|fixture|kept visible %02d\n' "$seed_ts" "$i" >>"$STATE_DIR/messages.log" + i=$((i + 1)) +done + +i=1 +while [ "$i" -le 20 ]; do + printf '%s|system|noise%02d joined the room\n' "$seed_ts" "$i" >>"$STATE_DIR/messages.log" + i=$((i + 1)) +done + +TNT_LANG=en TNT_RATE_LIMIT=0 "$BIN" --bind 127.0.0.1 \ + -p "$PORT" -d "$STATE_DIR" >"$STATE_DIR/server.log" 2>&1 & +SERVER_PID=$! + +SERVER_READY=0 +for _ in 1 2 3 4 5; do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "x Server failed to start" + sed -n '1,120p' "$STATE_DIR/server.log" + exit 1 + fi + if grep -q "TNT chat server listening" "$STATE_DIR/server.log"; then + SERVER_READY=1 + break + fi + sleep 1 +done + +if [ "$SERVER_READY" -eq 1 ]; then + echo "✓ server started" + PASS=$((PASS + 1)) +else + echo "x Server did not become ready" + sed -n '1,120p' "$STATE_DIR/server.log" + exit 1 +fi + +VIEW_SCRIPT="$STATE_DIR/mute-joins-view.expect" +cat >"$VIEW_SCRIPT" <"$STATE_DIR/mute-joins-view.log" 2>&1; then + echo "✓ :mute-joins fills latest view with non-join messages" + PASS=$((PASS + 1)) +else + echo "x :mute-joins latest view did not show older non-join messages" + sed -n '1,220p' "$STATE_DIR/mute-joins-view.log" + sed -n '1,120p' "$STATE_DIR/server.log" + FAIL=$((FAIL + 1)) +fi + +echo "" +echo "PASSED: $PASS" +echo "FAILED: $FAIL" +[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed" +exit "$FAIL" diff --git a/tests/test_user_lifecycle.sh b/tests/test_user_lifecycle.sh index 1f76400..fa23f2b 100755 --- a/tests/test_user_lifecycle.sh +++ b/tests/test_user_lifecycle.sh @@ -37,6 +37,7 @@ SSH_OPTS="-e none -tt -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/nul SSH_EXEC_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT" BOB_READY="$STATE_DIR/bob.ready" PRIVATE_SENT="$STATE_DIR/private.sent" +REPLY_SENT="$STATE_DIR/reply.sent" wait_for_health() { out="" @@ -92,7 +93,16 @@ exec touch "$BOB_READY" exec sh -c "while \[ ! -f '$PRIVATE_SENT' \]; do sleep 1; done" expect "私信" expect "alice" -expect "private lifecycle ping" +expect "private lifecycle second" +expect "private lifecycle first" +expect "q:关闭" +send -- "q" +expect "NORMAL" +send -- ":" +expect ":" +send -- "reply private lifecycle reply\r" +expect "私信已发送给 alice" +exec touch "$REPLY_SENT" expect "q:关闭" send -- "q" expect "NORMAL" @@ -201,12 +211,46 @@ send -- "q" expect "NORMAL" send -- ":" expect ":" -send -- "msg bob private lifecycle ping\r" +send -- "msg bob private lifecycle first\r" +expect "私信已发送给 bob" +expect "q:关闭" +send -- "q" +expect "NORMAL" +send -- ":" +expect ":" +send -- "msg bob private lifecycle second\r" expect "私信已发送给 bob" exec touch "$PRIVATE_SENT" expect "q:关闭" send -- "q" expect "NORMAL" +exec sh -c "while \[ ! -f '$REPLY_SENT' \]; do sleep 1; done" +send -- ":" +expect ":" +send -- "inbox\r" +expect "bob" +expect "private lifecycle reply" +expect "你 -> bob" +expect "private lifecycle second" +expect "private lifecycle first" +expect "q:关闭" +send -- "q" +expect "NORMAL" +send -- ":" +expect ":" +send -- "inbox clear\r" +expect "私信已清空" +expect "(空)" +expect "q:关闭" +send -- "q" +expect "NORMAL" +send -- ":" +expect ":" +send -- "reply should not send after clear\r" +expect "没有可回复的私信" +expect "q:关闭" +send -- "q" +expect "NORMAL" send -- ":" expect ":" send -- "nick alice2\r" @@ -244,6 +288,20 @@ else fi BOB_PID="" +if grep -q '.*alice.*private lifecycle second' "$STATE_DIR/bob.log" && + grep -Eq '私信.*[0-9]+ 新' "$STATE_DIR/bob.log" && + grep -q '\*.*alice.*private lifecycle second' "$STATE_DIR/bob.log" && + grep -Eq '私信.*[0-9]+ 新' "$STATE_DIR/alice.log" && + grep -q '\*.*bob.*private lifecycle reply' "$STATE_DIR/alice.log"; then + echo "✓ unread private messages are visibly marked in inbox" + PASS=$((PASS + 1)) +else + echo "✗ inbox unread marker missing" + sed -n '1,220p' "$STATE_DIR/bob.log" + sed -n '1,260p' "$STATE_DIR/alice.log" + FAIL=$((FAIL + 1)) +fi + TAIL_OUTPUT=$(ssh $SSH_EXEC_OPTS localhost "tail -n 10" 2>/dev/null || true) printf '%s\n' "$TAIL_OUTPUT" | grep -q 'hello lifecycle alpha' && printf '%s\n' "$TAIL_OUTPUT" | grep -q 'alice2 ships lifecycle' diff --git a/tests/unit/test_command_catalog.c b/tests/unit/test_command_catalog.c index 3dd67b8..fd8aa3d 100644 --- a/tests/unit/test_command_catalog.c +++ b/tests/unit/test_command_catalog.c @@ -35,6 +35,18 @@ TEST(matches_canonical_names_and_aliases) { assert(id == TNT_COMMAND_MSG); assert(strcmp(args, "alice hello") == 0); + assert(command_catalog_match("reply hello back", &id, &args)); + assert(id == TNT_COMMAND_REPLY); + assert(strcmp(args, "hello back") == 0); + + assert(command_catalog_match("r hello back", &id, &args)); + assert(id == TNT_COMMAND_REPLY); + assert(strcmp(args, "hello back") == 0); + + assert(command_catalog_match("inbox clear", &id, &args)); + assert(id == TNT_COMMAND_INBOX); + assert(strcmp(args, "clear") == 0); + assert(command_catalog_match("language zh", &id, &args)); assert(id == TNT_COMMAND_LANG); assert(strcmp(args, "zh") == 0); @@ -65,6 +77,11 @@ TEST(validates_argument_shapes) { assert(!command_catalog_args_valid(TNT_COMMAND_MSG, NULL)); assert(command_catalog_args_valid(TNT_COMMAND_MSG, "alice hello")); + assert(!command_catalog_args_valid(TNT_COMMAND_REPLY, "")); + assert(command_catalog_args_valid(TNT_COMMAND_REPLY, "hello back")); + assert(command_catalog_args_valid(TNT_COMMAND_INBOX, NULL)); + assert(command_catalog_args_valid(TNT_COMMAND_INBOX, "clear")); + assert(!command_catalog_args_valid(TNT_COMMAND_INBOX, "clear now")); assert(!command_catalog_args_valid(TNT_COMMAND_SEARCH, "")); assert(command_catalog_args_valid(TNT_COMMAND_SEARCH, "needle")); @@ -92,13 +109,15 @@ TEST(generates_localized_help_sections) { assert(strstr(en, ":users, :list, :who") != NULL); assert(strstr(en, "Show online users") != NULL); assert(strstr(en, ":msg ") != NULL); - assert(strstr(en, "Show private messages") != NULL); + assert(strstr(en, ":reply ") != NULL); + assert(strstr(en, "Show or clear private messages") != NULL); assert(strstr(en, ":support") == NULL); assert(strstr(zh, ":users, :list, :who") != NULL); assert(strstr(zh, "显示在线用户") != NULL); - assert(strstr(zh, "查看私信") != NULL); + assert(strstr(zh, "查看或清空私信") != NULL); assert(strstr(zh, ":msg ") != NULL); + assert(strstr(zh, ":reply ") != NULL); assert(strstr(zh, "<用户>") == NULL); assert(strstr(zh, "<消息>") == NULL); assert(strstr(zh, ":support") == NULL); @@ -120,6 +139,19 @@ TEST(generates_localized_usage) { assert(strcmp(zh, "用法: msg \n" " w \n") == 0); + en[0] = '\0'; + en_pos = 0; + command_catalog_append_usage(en, sizeof(en), &en_pos, + TNT_COMMAND_REPLY, UI_LANG_EN); + assert(strcmp(en, "Usage: reply \n" + " r \n") == 0); + + zh[0] = '\0'; + zh_pos = 0; + command_catalog_append_usage(zh, sizeof(zh), &zh_pos, + TNT_COMMAND_INBOX, UI_LANG_ZH); + assert(strcmp(zh, "用法: inbox [clear]\n") == 0); + en[0] = '\0'; en_pos = 0; command_catalog_append_usage(en, sizeof(en), &en_pos, diff --git a/tests/unit/test_i18n.c b/tests/unit/test_i18n.c index 838dbff..57c20b2 100644 --- a/tests/unit/test_i18n.c +++ b/tests/unit/test_i18n.c @@ -136,6 +136,10 @@ TEST(text_lookup_matches_language) { "online") != NULL); assert(strstr(i18n_text(UI_LANG_ZH, I18N_TITLE_ONLINE_FORMAT), "在线") != NULL); + assert(strstr(i18n_text(UI_LANG_EN, I18N_EMPTY_ROOM), + "No messages") != NULL); + assert(strstr(i18n_text(UI_LANG_ZH, I18N_EMPTY_FILTERED), + "可见") != NULL); assert(strstr(i18n_text(UI_LANG_EN, I18N_IDLE_TIMEOUT_FORMAT), "idle timeout") != NULL); assert(strstr(i18n_text(UI_LANG_ZH, I18N_IDLE_TIMEOUT_FORMAT), @@ -144,14 +148,34 @@ TEST(text_lookup_matches_language) { "Private message sent") != NULL); assert(strstr(i18n_text(UI_LANG_ZH, I18N_MSG_SENT_FORMAT), "私信已发送") != NULL); + assert(strstr(i18n_text(UI_LANG_EN, I18N_REPLY_NO_TARGET), + "No private message") != NULL); + assert(strstr(i18n_text(UI_LANG_ZH, I18N_REPLY_NO_TARGET), + "可回复") != NULL); assert(strstr(i18n_text(UI_LANG_EN, I18N_INBOX_TITLE), "Private messages") != NULL); assert(strstr(i18n_text(UI_LANG_ZH, I18N_INBOX_TITLE), "私信") != NULL); + assert(strstr(i18n_text(UI_LANG_EN, I18N_INBOX_SENT_TO_FORMAT), + "you ->") != NULL); + assert(strstr(i18n_text(UI_LANG_ZH, I18N_INBOX_SENT_TO_FORMAT), + "你 ->") != NULL); + assert(strstr(i18n_text(UI_LANG_EN, I18N_INBOX_CLEARED), + "cleared") != NULL); + assert(strstr(i18n_text(UI_LANG_ZH, I18N_INBOX_CLEARED), + "清空") != NULL); + assert(strstr(i18n_text(UI_LANG_EN, I18N_INBOX_UNREAD_FORMAT), + "new") != NULL); + assert(strstr(i18n_text(UI_LANG_ZH, I18N_INBOX_UNREAD_FORMAT), + "新") != NULL); assert(strstr(i18n_text(UI_LANG_EN, I18N_SEARCH_HEADER_FORMAT), "Search") != NULL); assert(strstr(i18n_text(UI_LANG_ZH, I18N_SEARCH_HEADER_FORMAT), "搜索") != NULL); + assert(strstr(i18n_text(UI_LANG_EN, I18N_LAST_EMPTY), + "No messages") != NULL); + assert(strstr(i18n_text(UI_LANG_ZH, I18N_SEARCH_EMPTY), + "匹配") != NULL); assert(strstr(i18n_text(UI_LANG_EN, I18N_LANG_CURRENT_FORMAT), "lang ") != NULL); assert(strstr(i18n_text(UI_LANG_ZH, I18N_LANG_CURRENT_FORMAT), diff --git a/tnt.1 b/tnt.1 index 86a134b..2eb5fa5 100644 --- a/tnt.1 +++ b/tnt.1 @@ -203,7 +203,7 @@ PageDown/PageUp Scroll full page down/up End/Home Jump to bottom/top g/G Jump to top/bottom / Search message history -i Switch to INSERT +i/a/o Switch to INSERT : Enter COMMAND mode ? Open full key reference Ctrl+C Disconnect @@ -220,7 +220,10 @@ l l. :name \fIname\fR Alias for :nick :msg \fIuser message\fR Send private message :w \fIuser text\fR Short alias for :msg -:inbox Show private messages +:reply \fItext\fR Reply to latest private message +:r \fItext\fR Short alias for :reply +:inbox Show private messages, newest first +:inbox clear Clear private messages for this session :last [\fIN\fR] Show last N messages from history (1\-50, default 10) :search \fIkeyword\fR Case\-insensitive search; shows the last 15 matches :mute\-joins Toggle join/leave system notifications on/off @@ -249,8 +252,19 @@ r Refresh live output (:inbox) .PP The .B :inbox -page refreshes automatically when a new private message arrives while it is -open. +page shows incoming messages and local sent-message copies for the current +session. It refreshes automatically when a new private message arrives while +it is open. Incoming unread messages are marked with +.B * +and counted in the inbox title until the inbox renders them. Use +.B :reply +or +.B :r +to answer the latest private-message peer. +.B :inbox clear +removes private messages and the reply target for this session. Private +messages are not written to +.IR messages.log . .SH EXEC INTERFACE Commands can be run non\-interactively for scripting: .PP @@ -340,10 +354,11 @@ libssh log verbosity from 0 to 4 (default: 1). .SH FILES .TP .I messages.log -Chat history in the TNT message log v1 format: +Public chat history in the TNT message log v1 format: RFC\ 3339 UTC pipe\-delimited records .RI ( timestamp | username | content ). -Stored in the state directory. +Stored in the state directory. Private messages and in-memory inbox state are +excluded. See .I docs/MESSAGE_LOG.md in the source distribution for parser and recovery rules.