Allow clearing private message inbox

This commit is contained in:
m1ngsama 2026-05-29 18:04:34 +08:00
parent 2fca031362
commit 845657e3c2
13 changed files with 80 additions and 12 deletions

View file

@ -99,6 +99,7 @@ Ctrl+C - Exit chat
:reply <text> - Reply to latest private message :reply <text> - Reply to latest private message
:r <text> - Short alias for :reply :r <text> - Short alias for :reply
:inbox - Show private messages :inbox - Show private messages
:inbox clear - Clear private messages for this session
:last [N] - Show last N messages from history (max 50, default 10) :last [N] - Show last N messages from history (max 50, default 10)
:search <keyword> - Search message history (shows last 15 matches) :search <keyword> - Search message history (shows last 15 matches)
:mute-joins - Toggle join/leave system notifications :mute-joins - Toggle join/leave system notifications
@ -115,6 +116,7 @@ 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 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. is open. `:reply text` and `:r text` send to the latest private-message peer.
Unread incoming private messages are marked with `*` until `:inbox` renders. Unread incoming private messages are marked with `*` until `:inbox` renders.
`: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`. Private messages are per-session only and are not written to `messages.log`.
**Special messages (INSERT mode)** **Special messages (INSERT mode)**

View file

@ -82,6 +82,7 @@ Common commands:
:msg <user> <message> send private message :msg <user> <message> send private message
:reply <message> reply to latest private message :reply <message> reply to latest private message
:inbox show private messages :inbox show private messages
:inbox clear clear private messages
:last [N] recent messages :last [N] recent messages
:search <keyword> search message history :search <keyword> search message history
:lang en|zh switch UI language :lang en|zh switch UI language

View file

@ -170,6 +170,8 @@ Recipients see incoming private messages; senders see local sent-message
copies. Unread incoming messages are marked with `*` until `:inbox` renders. copies. Unread incoming messages are marked with `*` until `:inbox` renders.
`:inbox` displays newest messages first, can be refreshed with `r`, and `:inbox` displays newest messages first, can be refreshed with `r`, and
refreshes automatically while open when a new private message arrives. 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` ### `help`

View file

@ -33,6 +33,7 @@ COMMANDS (COMMAND mode, prefix with :)
reply <text> reply to latest private message reply <text> reply to latest private message
r <text> alias for reply r <text> alias for reply
inbox show private messages, newest first 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) last [N] last N messages from log (default 10, max 50)
search <keyword> search full history (case-insensitive, 15 results) search <keyword> search full history (case-insensitive, 15 results)
mute-joins toggle join/leave notifications mute-joins toggle join/leave notifications

View file

@ -39,7 +39,8 @@ The product path should stay short:
- `:inbox` is live enough for normal chat use: it can be refreshed with `r` - `: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 and refreshes automatically when a new private message arrives while the
inbox is open. Incoming unread messages are marked with `*` until the inbox inbox is open. Incoming unread messages are marked with `*` until the inbox
renders them. 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 - `:reply` / `:r` keeps the private-message path keyboard-short: it answers
the latest private-message peer in the current session without retyping a the latest private-message peer in the current session without retyping a
username. username.
@ -54,8 +55,8 @@ The product path should stay short:
- first user opens `?`, checks `:users`, sends a public message, scrolls, uses - first user opens `?`, checks `:users`, sends a public message, scrolls, uses
`:last` and `:search` `:last` and `:search`
- first user toggles `:mute-joins`, sends two `:msg` messages, receives a - first user toggles `:mute-joins`, sends two `:msg` messages, receives a
`:reply`, confirms private-message copies in `:inbox`, changes nickname, `:reply`, confirms private-message copies in `:inbox`, clears the inbox,
sends `/me`, and exits changes nickname, sends `/me`, and exits
- second user opens `:inbox` before the private messages arrive, sees it - second user opens `:inbox` before the private messages arrive, sees it
auto-refresh after delivery, newest first, and replies without retyping the auto-refresh after delivery, newest first, and replies without retyping the
sender's username sender's username

View file

@ -49,6 +49,7 @@ typedef enum {
I18N_INBOX_TITLE, I18N_INBOX_TITLE,
I18N_INBOX_EMPTY, I18N_INBOX_EMPTY,
I18N_INBOX_SENT_TO_FORMAT, I18N_INBOX_SENT_TO_FORMAT,
I18N_INBOX_CLEARED,
I18N_NICK_INVALID, I18N_NICK_INVALID,
I18N_NICK_TAKEN_FORMAT, I18N_NICK_TAKEN_FORMAT,
I18N_NICK_UNCHANGED, I18N_NICK_UNCHANGED,

View file

@ -50,11 +50,11 @@ static const command_catalog_entry_t entries[] = {
}, },
{ {
{TNT_COMMAND_INBOX, "inbox", {"inbox", NULL}}, {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(":inbox", ":inbox"),
I18N_STRING("Show private messages", "查看私信"), I18N_STRING("Usage: inbox [clear]\n", "用法: inbox [clear]\n"),
I18N_STRING(":inbox", ":inbox"), 2, false, false
I18N_STRING("Usage: inbox\n", "用法: inbox\n"),
2, true, false
}, },
{ {
{TNT_COMMAND_NICK, "nick", {"nick", "name", NULL}}, {TNT_COMMAND_NICK, "nick", {"nick", "name", NULL}},
@ -239,6 +239,9 @@ bool command_catalog_args_valid(tnt_command_id_t id, const char *args) {
if (!entry) { if (!entry) {
return false; return false;
} }
if (id == TNT_COMMAND_INBOX) {
return !args || args[0] == '\0' || strcmp(args, "clear") == 0;
}
if (entry->no_args) { if (entry->no_args) {
return !args || args[0] == '\0'; return !args || args[0] == '\0';
} }

View file

@ -179,6 +179,15 @@ static void append_inbox_output(client_t *client, char *output,
} }
} }
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) { bool commands_refresh_active_output(client_t *client) {
char output[MAX_COMMAND_OUTPUT_LEN] = {0}; char output[MAX_COMMAND_OUTPUT_LEN] = {0};
size_t pos = 0; size_t pos = 0;
@ -361,8 +370,17 @@ void commands_dispatch(client_t *client) {
} }
} else if (command_id == TNT_COMMAND_INBOX) { } else if (command_id == TNT_COMMAND_INBOX) {
output_kind = TNT_COMMAND_OUTPUT_INBOX; const char *inbox_arg = arg;
append_inbox_output(client, output, sizeof(output), &pos); while (*inbox_arg == ' ') inbox_arg++;
if (strcmp(inbox_arg, "clear") == 0) {
clear_inbox(client);
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->ui_lang,
I18N_INBOX_CLEARED));
} else {
output_kind = TNT_COMMAND_OUTPUT_INBOX;
append_inbox_output(client, output, sizeof(output), &pos);
}
} else if (command_id == TNT_COMMAND_NICK) { } else if (command_id == TNT_COMMAND_NICK) {
const char *new_name = arg; const char *new_name = arg;

View file

@ -137,6 +137,10 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = {
"you -> %s", "you -> %s",
"你 -> %s" "你 -> %s"
), ),
[I18N_INBOX_CLEARED] = I18N_STRING(
"Private messages cleared\n",
"私信已清空\n"
),
[I18N_NICK_INVALID] = I18N_STRING( [I18N_NICK_INVALID] = I18N_STRING(
"Invalid username\n", "Invalid username\n",
"用户名无效\n" "用户名无效\n"

View file

@ -238,6 +238,20 @@ send -- "q"
expect "NORMAL" expect "NORMAL"
send -- ":" send -- ":"
expect ":" expect ":"
send -- "inbox clear\r"
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" send -- "nick alice2\r"
expect "昵称已修改: alice -> alice2" expect "昵称已修改: alice -> alice2"
expect "q:关闭" expect "q:关闭"

View file

@ -43,6 +43,10 @@ TEST(matches_canonical_names_and_aliases) {
assert(id == TNT_COMMAND_REPLY); assert(id == TNT_COMMAND_REPLY);
assert(strcmp(args, "hello back") == 0); 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(command_catalog_match("language zh", &id, &args));
assert(id == TNT_COMMAND_LANG); assert(id == TNT_COMMAND_LANG);
assert(strcmp(args, "zh") == 0); assert(strcmp(args, "zh") == 0);
@ -75,6 +79,9 @@ TEST(validates_argument_shapes) {
assert(command_catalog_args_valid(TNT_COMMAND_MSG, "alice hello")); 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, ""));
assert(command_catalog_args_valid(TNT_COMMAND_REPLY, "hello back")); 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, ""));
assert(command_catalog_args_valid(TNT_COMMAND_SEARCH, "needle")); assert(command_catalog_args_valid(TNT_COMMAND_SEARCH, "needle"));
@ -103,12 +110,12 @@ TEST(generates_localized_help_sections) {
assert(strstr(en, "Show online users") != NULL); assert(strstr(en, "Show online users") != NULL);
assert(strstr(en, ":msg <user> <message>") != NULL); assert(strstr(en, ":msg <user> <message>") != NULL);
assert(strstr(en, ":reply <message>") != NULL); assert(strstr(en, ":reply <message>") != NULL);
assert(strstr(en, "Show private messages") != NULL); assert(strstr(en, "Show or clear private messages") != NULL);
assert(strstr(en, ":support") == NULL); assert(strstr(en, ":support") == NULL);
assert(strstr(zh, ":users, :list, :who") != NULL); assert(strstr(zh, ":users, :list, :who") != NULL);
assert(strstr(zh, "显示在线用户") != NULL); assert(strstr(zh, "显示在线用户") != NULL);
assert(strstr(zh, "查看私信") != NULL); assert(strstr(zh, "查看或清空私信") != NULL);
assert(strstr(zh, ":msg <user> <message>") != NULL); assert(strstr(zh, ":msg <user> <message>") != NULL);
assert(strstr(zh, ":reply <message>") != NULL); assert(strstr(zh, ":reply <message>") != NULL);
assert(strstr(zh, "<用户>") == NULL); assert(strstr(zh, "<用户>") == NULL);
@ -139,6 +146,12 @@ TEST(generates_localized_usage) {
assert(strcmp(en, "Usage: reply <message>\n" assert(strcmp(en, "Usage: reply <message>\n"
" r <message>\n") == 0); " r <message>\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[0] = '\0';
en_pos = 0; en_pos = 0;
command_catalog_append_usage(en, sizeof(en), &en_pos, command_catalog_append_usage(en, sizeof(en), &en_pos,

View file

@ -160,6 +160,10 @@ TEST(text_lookup_matches_language) {
"you ->") != NULL); "you ->") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_INBOX_SENT_TO_FORMAT), assert(strstr(i18n_text(UI_LANG_ZH, I18N_INBOX_SENT_TO_FORMAT),
"你 ->") != NULL); "你 ->") != 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_SEARCH_HEADER_FORMAT), assert(strstr(i18n_text(UI_LANG_EN, I18N_SEARCH_HEADER_FORMAT),
"Search") != NULL); "Search") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_SEARCH_HEADER_FORMAT), assert(strstr(i18n_text(UI_LANG_ZH, I18N_SEARCH_HEADER_FORMAT),

6
tnt.1
View file

@ -223,6 +223,7 @@ l l.
:reply \fItext\fR Reply to latest private message :reply \fItext\fR Reply to latest private message
:r \fItext\fR Short alias for :reply :r \fItext\fR Short alias for :reply
:inbox Show private messages, newest first :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) :last [\fIN\fR] Show last N messages from history (1\-50, default 10)
:search \fIkeyword\fR Case\-insensitive search; shows the last 15 matches :search \fIkeyword\fR Case\-insensitive search; shows the last 15 matches
:mute\-joins Toggle join/leave system notifications on/off :mute\-joins Toggle join/leave system notifications on/off
@ -259,7 +260,10 @@ until the inbox renders them. Use
.B :reply .B :reply
or or
.B :r .B :r
to answer the latest private-message peer. Private messages are not written to 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 . .IR messages.log .
.SH EXEC INTERFACE .SH EXEC INTERFACE
Commands can be run non\-interactively for scripting: Commands can be run non\-interactively for scripting: