v0.0.14 - Finalize ncurses footer-log/pager UX: route signer+tweets to nt_log, remove runtime stdout prints, add main-menu View log

This commit is contained in:
Laan Tungir
2026-05-06 18:25:01 -04:00
parent 918cc618a9
commit 0d64b14548
21 changed files with 2638 additions and 1607 deletions

View File

@@ -9,6 +9,7 @@ add_compile_options(-Wall -Wextra -D_GNU_SOURCE)
find_package(PkgConfig REQUIRED)
pkg_check_modules(LIBCURL REQUIRED libcurl)
find_package(Curses REQUIRED)
# nt application sources
file(GLOB NT_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/src/*.c")
@@ -35,7 +36,10 @@ target_include_directories(nostr_core_lib PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/resources/nostr_core_lib/cjson
)
add_executable(nt ${NT_SOURCES})
add_executable(nt
${NT_SOURCES}
${CMAKE_CURRENT_SOURCE_DIR}/resources/tui_ncurses/tui_ncurses.c
)
target_include_directories(nt PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/include
@@ -43,6 +47,8 @@ target_include_directories(nt PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/resources/nostr_core_lib/nostr_core
${CMAKE_CURRENT_SOURCE_DIR}/resources/nostr_core_lib/nostr_websocket
${CMAKE_CURRENT_SOURCE_DIR}/resources/nostr_core_lib/cjson
${CMAKE_CURRENT_SOURCE_DIR}/resources/tui_ncurses
${CURSES_INCLUDE_DIRS}
)
if(CMAKE_EXE_LINKER_FLAGS MATCHES "(^| )-static( |$)")
@@ -63,6 +69,7 @@ target_link_libraries(nt PRIVATE
nostr_core_lib
sqlite3
${NT_CURL_LIBS}
${CURSES_LIBRARIES}
secp256k1
ssl
crypto

View File

@@ -123,12 +123,24 @@ Local SQLite at `~/.nostr/nostr.db` is the only persistent state between invocat
- C99 compiler (gcc / clang)
- CMake ≥ 3.10
**Libraries (system):**
- `ncurses` — terminal UI
### Runtime Dependencies
- `libncurses` (`ncurses-dev` when building on many distros) — TUI rendering/runtime dependency (uses alternate screen buffer)
- `sqlite3` — local cache
- `libcurl` — HTTP (NIP-11, AI APIs, nostr.watch)
- `openssl` — TLS for WebSockets
TUI runtime note: `nt` uses the ncurses alternate screen buffer with a footer-log status line for latest action results; use the **View log** main menu entry to open the full scrollable action history.
### TUI Features
- Arrow-key navigation + shortcut keys in all menus
- Scrollable tables with PgUp/PgDn
- Footer status bar showing latest action result
- **View log** menu entry to scroll through full action history
- Full-screen pager for long content (event JSON, blog posts, relay info)
- Modal dialogs for errors and confirmations
**Libraries (vendored in `resources/nostr_core_lib`):**
- `nostr_core_lib` — Nostr protocol, NIPs, crypto, relay pool, websocket
- `cJSON` — JSON parsing

View File

@@ -1,68 +1,40 @@
#ifndef TUI_H
#define TUI_H
#include "tui_ncurses.h"
#define TUI_KEY_RESIZE -2
#define NT_HK(first, rest) "\033[4m" first "\033[0m" rest
typedef struct {
const char *breadcrumb;
const char *title_suffix;
} tui_view_t;
/* printf-like writer into the body window; appends newline. Tracks cursor row. */
void nt_print(const char *fmt, ...);
/* Reset body window cursor to top + werase */
void nt_print_reset(void);
/* Bracket external editor spawn: cleanup curses, run spawn, re-init curses */
int nt_run_external_editor(int (*spawn)(void *user), void *user);
/* Build a TuiFrame with the current app title + given breadcrumb */
TuiFrame nt_frame(const char *breadcrumb);
/* Build a TuiStatus with current user info */
TuiStatus nt_status(void);
/* Set the app name/version used by nt_frame */
void nt_set_app_info(const char *name, const char *version);
/* OSC 2 window title (orthogonal to ncurses) */
void nt_set_window_title(const char *title);
/* Initialize terminal input mode (non-canonical, no echo). */
void tui_init(void);
/* Set the user label shown in the header. NULL or "" hides it. */
void nt_set_user(const char *user);
/* Restore terminal settings to their original state. */
void tui_cleanup(void);
/* Footer-log: append a timestamped line to the global log ring buffer.
* The latest line is shown in the footer status area. */
void nt_log(const char *fmt, ...);
/* Install SIGWINCH handler for resize-aware redraw loops. */
void tui_install_resize_handler(void);
/* Open the full log in a scrollable pager (calls tuin_pager_run). */
void nt_log_show(void);
/* Consume pending resize flag (returns 1 if a resize was pending). */
int tui_consume_resize_flag(void);
/* Clear the log buffer. */
void nt_log_clear(void);
/* Set app title used by anchored frame renderers. */
void tui_set_app_title(const char *title);
/* Clear the screen area by printing terminal-height blank lines. */
void tui_clear_screen(void);
/* Print formatted output and render ^_...^: hotkey highlights. */
void tui_print(const char *fmt, ...);
/* Print full-width horizontal separator using '='. */
void tui_print_hr(void);
/* Print centered text in the current terminal width. */
void tui_print_centered(const char *text);
/* Render anchored top frame (separator/title/separator/breadcrumb + blank lines). */
void tui_render_top_frame(const tui_view_t *view);
/* Column where centered menu block should start. */
int tui_centered_menu_start_col(void);
/* Print one menu line aligned to centered menu start. */
void tui_print_menu_item(const char *fmt, ...);
/* Begin canonical continuous frame and reset line counting. */
void tui_begin_frame(const tui_view_t *view);
/* End frame with anchored prompt near bottom, then read line. */
int tui_end_frame_with_prompt(const char *prompt, char *buf, int bufsize);
/* Render startup splash screen and wait for Enter. */
void tui_show_splash(void);
/* Prompt and read a blocking input line into buf, returns length. */
int tui_get_line(const char *prompt, char *buf, int bufsize);
/* Wait for and return a single key press byte/sequence lead byte. */
int tui_get_key(void);
/* Query current terminal width/height in character cells. */
void tui_get_terminal_size(int *width, int *height);
/* Set terminal window title using ANSI OSC sequence. */
void tui_set_window_title(const char *title);
/* Show arbitrary text in a full-screen scrollable pager. */
void nt_pager_show_text(const char *breadcrumb, const char *text);
#endif /* TUI_H */

View File

@@ -77,19 +77,27 @@ static char *editor_read_file_malloc(const char *path) {
return buf;
}
static int editor_run_command(const char *tmp_path, const char *editor_env) {
typedef struct {
const char *tmp_path;
const char *editor_env;
} editor_spawn_ctx_t;
static int editor_spawn(void *user) {
editor_spawn_ctx_t *ctx = (editor_spawn_ctx_t *)user;
const char *tmp_path;
const char *editor_env;
pid_t pid;
int status = 0;
if (!tmp_path) {
if (!ctx || !ctx->tmp_path) {
return -1;
}
tui_cleanup();
tmp_path = ctx->tmp_path;
editor_env = ctx->editor_env;
pid = fork();
if (pid < 0) {
tui_init();
return -1;
}
@@ -148,8 +156,6 @@ static int editor_run_command(const char *tmp_path, const char *editor_env) {
}
}
tui_init();
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
return -1;
}
@@ -157,6 +163,18 @@ static int editor_run_command(const char *tmp_path, const char *editor_env) {
return 0;
}
static int editor_run_command(const char *tmp_path, const char *editor_env) {
editor_spawn_ctx_t ctx;
if (!tmp_path) {
return -1;
}
ctx.tmp_path = tmp_path;
ctx.editor_env = editor_env;
return nt_run_external_editor(editor_spawn, &ctx);
}
char *editor_launch(const char *initial_content) {
char template_path[] = "/tmp/nt_editor_XXXXXX";
int fd;

View File

@@ -11,9 +11,10 @@
*/
#define NT_VERSION_MAJOR 0
#define NT_VERSION_MINOR 0
#define NT_VERSION_PATCH 13
#define NT_VERSION "v0.0.13"
#define NT_VERSION_PATCH 14
#define NT_VERSION "v0.0.14"
#include <ncurses.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -33,153 +34,121 @@ void menu_diary(void);
void menu_ai(void);
void menu_ecash(void);
static void nt_update_app_title(void) {
nt_set_app_info("NOSTR TERMINAL", NT_VERSION);
if (g_state.logged_in && g_state.user_name[0] != '\0') {
nt_set_user(g_state.user_name);
} else {
nt_set_user("");
}
}
static void menu_show_loaded_events(void) {
static const tui_view_t view = {
"> Main Menu > Loaded Events",
NULL,
};
char input[8];
char buf[8192];
int pos = 0;
tui_begin_frame(&view);
pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "KIND 0 (metadata content):\n%s\n\n",
g_state.kind0_json ? g_state.kind0_json : "(not loaded)");
pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "KIND 3 (follows event):\n%s\n\n",
g_state.kind3_json ? g_state.kind3_json : "(not loaded)");
pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "KIND 10002 (relay list event):\n%s\n\n",
g_state.kind10002_json ? g_state.kind10002_json : "(not loaded)");
pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "KIND 10096 (blossom event):\n%s\n\n",
g_state.kind10096_json ? g_state.kind10096_json : "(not loaded)");
pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "KIND 17375 (wallet event):\n%s\n\n",
g_state.kind17375_json ? g_state.kind17375_json : "(not loaded)");
pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "USER-SETTINGS (kind 30078, d=user-settings):\n%s\n",
g_state.user_settings_json ? g_state.user_settings_json : "(not loaded)");
tui_print("KIND 0 (metadata content):");
tui_print("%s", g_state.kind0_json ? g_state.kind0_json : "(not loaded)");
tui_print("");
tui_print("KIND 3 (follows event):");
tui_print("%s", g_state.kind3_json ? g_state.kind3_json : "(not loaded)");
tui_print("");
tui_print("KIND 10002 (relay list event):");
tui_print("%s", g_state.kind10002_json ? g_state.kind10002_json : "(not loaded)");
tui_print("");
tui_print("KIND 10096 (blossom event):");
tui_print("%s", g_state.kind10096_json ? g_state.kind10096_json : "(not loaded)");
tui_print("");
tui_print("KIND 17375 (wallet event):");
tui_print("%s", g_state.kind17375_json ? g_state.kind17375_json : "(not loaded)");
tui_print("");
tui_print("USER-SETTINGS (kind 30078, d=user-settings):");
tui_print("%s", g_state.user_settings_json ? g_state.user_settings_json : "(not loaded)");
tui_print("");
(void)tui_end_frame_with_prompt("Press Enter to return >", input, (int)sizeof(input));
nt_pager_show_text("> Main Menu > Loaded Events", buf);
}
void menu_main(void) {
static const tui_view_t main_view = {
"> Main Menu",
NULL,
static const TuiMenuItem MAIN_ITEMS[] = {
{NT_HK("W", "rite"), 'w'},
{NT_HK("T", "weet"), 't'},
{NT_HK("P", "rofile"), 'p'},
{NT_HK("R", "elays"), 'r'},
{NT_HK("F", "ollows"), 'f'},
{NT_HK("K", "ind/event dump"), 'k'},
{NT_HK("N", "otifications"), 'n'},
{NT_HK("B", "logs/posts"), 'b'},
{NT_HK("L", "ive feeds"), 'l'},
{NT_HK("M", "essage"), 'm'},
{"To" NT_HK("d", "o"), 'd'},
{NT_HK("J", "ournal"), 'j'},
{NT_HK("A", "i"), 'a'},
{NT_HK("E", "cash"), 'e'},
{NT_HK("V", "iew log"), 'v'},
{NT_HK("Q", "uit"), 'q'},
};
char input[64];
TuiMenuState state = {0};
while (1) {
(void)tui_consume_resize_flag();
tui_begin_frame(&main_view);
tui_print("User: %s", g_state.user_name);
tui_print("");
if (!g_state.logged_in) {
tui_print_menu_item("^_L^:og in");
tui_print_menu_item("^_Q^:uit");
} else {
tui_print_menu_item("^_W^:rite");
tui_print_menu_item("^_T^:weet");
tui_print_menu_item("^_P^:rofile");
tui_print_menu_item("^_R^:elays");
tui_print_menu_item("^_F^:ollows");
tui_print_menu_item("^_K^:ind/event dump");
tui_print_menu_item("^_N^:otifications");
tui_print_menu_item("^_B^:logs/posts");
tui_print_menu_item("^_L^:ive feeds");
tui_print_menu_item("^_M^:essage");
tui_print_menu_item("To^_d^:o");
tui_print_menu_item("^_J^:ournal");
tui_print_menu_item("^_A^:i");
tui_print_menu_item("^_E^:cash");
tui_print_menu_item("^_Q^:uit");
}
(void)tui_end_frame_with_prompt(">", input, (int)sizeof(input));
if (input[0] == 'q' || input[0] == 'Q' || input[0] == 'x' || input[0] == 'X') {
tui_print("\nHasta luego.\n");
break;
}
if (!g_state.logged_in) {
if (input[0] == '\0' || input[0] == 'l' || input[0] == 'L') {
menu_login();
menu_login();
if (!g_state.logged_in) {
break;
}
continue;
}
switch (input[0]) {
case 'w':
case 'W':
nt_update_app_title();
TuiFrame frame = nt_frame("> Main Menu");
TuiMenu menu = {MAIN_ITEMS, 16};
TuiStatus status = nt_status();
int idx = tuin_menu_run(&frame, &menu, &status, &state);
if (idx < 0 || idx == 15) {
break;
}
switch (idx) {
case 0:
menu_write();
break;
case 't':
case 'T':
case 1:
menu_tweet();
break;
case 'p':
case 'P':
case 2:
menu_profile();
break;
case 'r':
case 'R':
case 3:
menu_relays();
break;
case 'f':
case 'F':
case 4:
menu_follows();
break;
case 'n':
case 'N':
menu_notifications();
break;
case 'k':
case 'K':
case 5:
menu_show_loaded_events();
break;
case 'b':
case 'B':
case 'o':
case 'O':
case 6:
menu_notifications();
break;
case 7:
menu_posts();
break;
case 'l':
case 'L':
case 'v':
case 'V':
case 8:
menu_live();
break;
case 'm':
case 'M':
case 9:
menu_dm();
break;
case 'd':
case 'D':
case 10:
menu_todo();
break;
case 'j':
case 'J':
case 'i':
case 'I':
case 11:
menu_diary();
break;
case 'a':
case 'A':
case 12:
menu_ai();
break;
case 'e':
case 'E':
case 13:
menu_ecash();
break;
case 14:
nt_log_show();
break;
default:
break;
}
@@ -190,6 +159,9 @@ int main(int argc, char *argv[]) {
(void)argc;
(void)argv;
snprintf(g_state.version, sizeof(g_state.version), "%s", NT_VERSION);
state_init();
if (db_open() != 0) {
fprintf(stderr, "Failed to open database.\n");
return 1;
@@ -200,32 +172,51 @@ int main(int argc, char *argv[]) {
return 1;
}
tui_init();
tui_install_resize_handler();
tui_set_window_title("Nostr Terminal");
state_init();
snprintf(g_state.version, sizeof(g_state.version), "%s", NT_VERSION);
{
char app_title[64];
snprintf(app_title, sizeof(app_title), "NOSTR TERMINAL %s", g_state.version);
tui_set_app_title(app_title);
}
if (nostr_init() != 0) {
fprintf(stderr, "Failed to initialize nostr library.\n");
state_cleanup();
tui_cleanup();
db_close();
return 1;
}
tui_show_splash();
tuin_init();
nt_set_app_info("NOSTR TERMINAL", NT_VERSION);
nt_set_window_title("Nostr Terminal");
/* Splash screen */
{
TuiFrame frame = nt_frame("> Splash");
TuiStatus status = {"Press any key to continue"};
WINDOW *body = (WINDOW *)tuin_body_window();
int h = 0;
int w = 0;
const char *title = "NOSTR TERMINAL";
int len = (int)strlen(title);
int col;
int row;
tuin_render_header(&frame);
getmaxyx(body, h, w);
col = (w - len) / 2;
if (col < 0) {
col = 0;
}
row = h / 2;
werase(body);
mvwprintw(body, row, col, "%s", title);
mvwprintw(body, row + 1, col, "%s", NT_VERSION);
wnoutrefresh(body);
tuin_render_footer(&status);
doupdate();
tuin_get_key();
}
menu_main();
nostr_cleanup();
tuin_cleanup();
signer_shutdown();
nostr_cleanup();
state_cleanup();
tui_cleanup();
db_close();
return 0;
}

View File

@@ -5,6 +5,7 @@
#include "../resources/nostr_core_lib/cjson/cJSON.h"
#include <curl/curl.h>
#include <ncurses.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -491,15 +492,11 @@ static char *ai_extract_response(const char *resp_json) {
return out;
}
static void ai_show_response_stream(const char *text) {
nt_pager_show_text("> Main Menu > AI > Chat > Response", text ? text : "");
}
static void ai_chat(const ai_prefs_t *prefs) {
static const tui_view_t ai_chat_view = {
"> Main Menu > AI > Chat",
NULL,
};
static const tui_view_t ai_response_view = {
"> Main Menu > AI > Chat > Response",
NULL,
};
char prompt[4096];
cJSON *root;
cJSON *messages;
@@ -508,23 +505,18 @@ static void ai_chat(const ai_prefs_t *prefs) {
char auth[4096];
char *resp_json;
char *answer;
char hold[512];
if (!prefs) {
return;
}
tui_begin_frame(&ai_chat_view);
tui_print("Model: %s", prefs->model ? prefs->model : "");
(void)tui_end_frame_with_prompt("prompt >", prompt, (int)sizeof(prompt));
if (prompt[0] == '\0') {
prompt[0] = '\0';
if (tuin_prompt("prompt >", "", prompt, sizeof(prompt)) != 0 || prompt[0] == '\0') {
return;
}
if (!prefs->api_key || prefs->api_key[0] == '\0') {
tui_begin_frame(&ai_chat_view);
tui_print("AI API key is empty. Configure settings first.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", hold, (int)sizeof(hold));
tuin_notice("AI API key is empty. Configure settings first.");
return;
}
@@ -554,27 +546,25 @@ static void ai_chat(const ai_prefs_t *prefs) {
}
snprintf(auth, sizeof(auth), "Authorization: Bearer %s", prefs->api_key ? prefs->api_key : "");
nt_log("Requesting AI response...");
resp_json = ai_http_post(prefs->endpoint ? prefs->endpoint : "", auth, body);
free(body);
if (!resp_json) {
tui_begin_frame(&ai_chat_view);
tui_print("AI request failed.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", hold, (int)sizeof(hold));
tuin_notice("AI request failed.");
return;
}
answer = ai_extract_response(resp_json);
free(resp_json);
tui_begin_frame(&ai_response_view);
if (answer) {
tui_print("%s", answer);
nt_log("AI response received.");
ai_show_response_stream(answer);
} else {
tui_print("Failed to parse AI response.");
tuin_notice("Failed to parse AI response.");
}
free(answer);
(void)tui_end_frame_with_prompt("Press Enter to continue >", hold, (int)sizeof(hold));
}
static void ai_choose_model(ai_prefs_t *prefs) {
@@ -584,8 +574,8 @@ static void ai_choose_model(ai_prefs_t *prefs) {
return;
}
tui_get_line("model >", model, (int)sizeof(model));
if (model[0] == '\0') {
model[0] = '\0';
if (tuin_prompt("model >", prefs->model ? prefs->model : "", model, sizeof(model)) != 0 || model[0] == '\0') {
return;
}
@@ -593,84 +583,76 @@ static void ai_choose_model(ai_prefs_t *prefs) {
}
static void ai_settings(ai_prefs_t *prefs) {
static const tui_view_t ai_settings_view = {
"> Main Menu > AI > Settings",
NULL,
};
char endpoint[512];
char key[1024];
char model[256];
char hold[512];
if (!prefs) {
return;
}
tui_begin_frame(&ai_settings_view);
tui_print("Leave empty to keep current value.");
tui_print("Current endpoint: %s", prefs->endpoint ? prefs->endpoint : "");
(void)tui_end_frame_with_prompt("new endpoint >", endpoint, (int)sizeof(endpoint));
if (endpoint[0] != '\0') {
endpoint[0] = '\0';
if (tuin_prompt("new endpoint >", prefs->endpoint ? prefs->endpoint : "", endpoint, sizeof(endpoint)) == 0 &&
endpoint[0] != '\0') {
ai_set_string(&prefs->endpoint, endpoint);
}
tui_begin_frame(&ai_settings_view);
tui_print("Current API key: %s", (prefs->api_key && prefs->api_key[0] != '\0') ? "(set)" : "(empty)");
(void)tui_end_frame_with_prompt("new api key >", key, (int)sizeof(key));
if (key[0] != '\0') {
key[0] = '\0';
if (tuin_prompt("new api key >", "", key, sizeof(key)) == 0 && key[0] != '\0') {
ai_set_string(&prefs->api_key, key);
}
tui_begin_frame(&ai_settings_view);
tui_print("Current model: %s", prefs->model ? prefs->model : "");
(void)tui_end_frame_with_prompt("new model >", model, (int)sizeof(model));
if (model[0] != '\0') {
model[0] = '\0';
if (tuin_prompt("new model >", prefs->model ? prefs->model : "", model, sizeof(model)) == 0 &&
model[0] != '\0') {
ai_set_string(&prefs->model, model);
}
tui_begin_frame(&ai_settings_view);
if (ai_save_prefs(prefs) != 0) {
tui_print("Failed to save prefs.");
tuin_notice("Failed to save prefs.");
} else {
tui_print("Prefs saved.");
nt_log("Prefs saved.");
}
(void)tui_end_frame_with_prompt("Press Enter to continue >", hold, (int)sizeof(hold));
}
void menu_ai(void) {
static const tui_view_t ai_view = {
"> Main Menu > AI",
NULL,
static const TuiMenuItem AI_ITEMS[] = {
{NT_HK("C", "hat"), 'c'},
{NT_HK("M", "odel"), 'm'},
{NT_HK("S", "ettings"), 's'},
{NT_HK("X", "Exit"), 'x'},
};
ai_prefs_t prefs;
char input[512];
TuiMenuState menu_state = {0};
ai_prefs_init(&prefs);
(void)ai_load_prefs(&prefs);
while (1) {
tui_begin_frame(&ai_view);
tui_print("Model: %s", prefs.model ? prefs.model : "");
tui_print("Endpoint: %s", prefs.endpoint ? prefs.endpoint : "");
tui_print_menu_item("^_C^:hat");
tui_print_menu_item("^_M^:odel");
tui_print_menu_item("^_S^:ettings");
tui_print_menu_item("^_X^:Exit");
TuiFrame frame = nt_frame("> Main Menu > AI");
char status_text[1024];
TuiStatus status;
TuiMenu menu = {AI_ITEMS, (int)(sizeof(AI_ITEMS) / sizeof(AI_ITEMS[0]))};
int idx;
(void)tui_end_frame_with_prompt(">", input, (int)sizeof(input));
snprintf(status_text,
sizeof(status_text),
"Model: %s | Endpoint: %s",
prefs.model ? prefs.model : "",
prefs.endpoint ? prefs.endpoint : "");
status.text = status_text;
if (input[0] == 'b' || input[0] == 'B' ||
input[0] == 'x' || input[0] == 'X' ||
input[0] == 'q' || input[0] == 'Q') {
idx = tuin_menu_run(&frame, &menu, &status, &menu_state);
if (idx < 0 || idx == 3) {
break;
}
if (input[0] == 'c' || input[0] == 'C') {
if (idx == 0) {
ai_chat(&prefs);
} else if (input[0] == 'm' || input[0] == 'M') {
} else if (idx == 1) {
ai_choose_model(&prefs);
} else if (input[0] == 's' || input[0] == 'S') {
} else if (idx == 2) {
ai_settings(&prefs);
}
}

View File

@@ -9,6 +9,7 @@
#include "../resources/nostr_core_lib/nostr_core/nip004.h"
#include "../resources/nostr_core_lib/nostr_core/utils.h"
#include <ncurses.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -20,6 +21,35 @@ typedef struct {
char *content;
} diary_entry_t;
typedef struct {
const diary_entry_t *entries;
int count;
} diary_table_ctx_t;
typedef struct {
const char *initial;
char *edited;
} diary_editor_ctx_t;
static int diary_editor_spawn(void *user) {
diary_editor_ctx_t *ctx = (diary_editor_ctx_t *)user;
if (!ctx) {
return -1;
}
ctx->edited = editor_launch(ctx->initial);
return 0;
}
static char *diary_launch_editor(const char *initial) {
diary_editor_ctx_t ctx;
ctx.initial = initial;
ctx.edited = NULL;
if (nt_run_external_editor(diary_editor_spawn, &ctx) != 0) {
return NULL;
}
return ctx.edited;
}
static char *diary_strdup(const char *s) {
size_t n;
char *out;
@@ -190,13 +220,13 @@ static int diary_save_entry(const char *date_d, const char *plain) {
}
if (diary_encrypt_for_self(plain, &cipher) != 0) {
tui_print("Failed to encrypt diary entry.");
tuin_notice("Failed to encrypt diary entry.");
return -1;
}
tags = diary_make_d_tag(date_d);
if (!tags) {
tui_print("Failed to build diary tags.");
tuin_notice("Failed to build diary tags.");
free(cipher);
return -1;
}
@@ -206,11 +236,11 @@ static int diary_save_entry(const char *date_d, const char *plain) {
free(cipher);
if (posted <= 0) {
tui_print("Diary publish failed (accepted by 0 relays).");
tuin_notice("Diary publish failed (accepted by 0 relays).");
return -1;
}
tui_print("Diary saved.");
nt_log("Diary saved.");
return 0;
}
@@ -249,15 +279,48 @@ static int diary_cmp_desc_date(const void *a, const void *b) {
return strcmp(eb->date_d, ea->date_d);
}
static void diary_show_entry(const diary_entry_t *entry) {
const char *content;
size_t total;
char *text;
if (!entry) {
return;
}
content = entry->content ? entry->content : "";
total = strlen("Date: ") + strlen(entry->date_d ? entry->date_d : "") + strlen("\n\n") + strlen(content) + 1U;
text = (char *)malloc(total);
if (!text) {
tuin_notice("Out of memory while preparing diary entry view.");
return;
}
snprintf(text, total, "Date: %s\n\n%s", entry->date_d ? entry->date_d : "", content);
nt_pager_show_text("> Main Menu > Diary > Past Entries > View", text);
free(text);
}
static void diary_table_get_cell(int row, int col, char *out, size_t out_size, void *user_data) {
const diary_table_ctx_t *ctx = (const diary_table_ctx_t *)user_data;
if (!out || out_size == 0U) {
return;
}
out[0] = '\0';
if (!ctx || row < 0 || row >= ctx->count) {
return;
}
if (col == 0) {
snprintf(out, out_size, "%d", row + 1);
} else if (col == 1) {
snprintf(out, out_size, "%s", ctx->entries[row].date_d ? ctx->entries[row].date_d : "(no d-tag)");
}
}
static void diary_browse_past(void) {
static const tui_view_t diary_past_view = {
"> Main Menu > Diary > Past Entries",
NULL,
};
static const tui_view_t diary_entry_view = {
"> Main Menu > Diary > Past Entries > View",
NULL,
};
const char **read_relays;
int relay_count = 0;
char filter[256];
@@ -265,8 +328,8 @@ static void diary_browse_past(void) {
int event_count = 0;
diary_entry_t *entries = NULL;
int entries_count = 0;
int selected = 0;
int i;
char input[64];
read_relays = state_get_read_relays(&relay_count);
snprintf(filter,
@@ -275,9 +338,7 @@ static void diary_browse_past(void) {
g_state.npub_hex);
if (nt_query_sync(filter, read_relays, relay_count, 7000, &events, &event_count) != 0) {
tui_begin_frame(&diary_past_view);
tui_print("Failed to query diary entries.");
(void)tui_end_frame_with_prompt("Press Enter to exit >", input, (int)sizeof(input));
tuin_notice("Failed to query diary entries.");
return;
}
@@ -334,32 +395,53 @@ static void diary_browse_past(void) {
qsort(entries, (size_t)entries_count, sizeof(diary_entry_t), diary_cmp_desc_date);
}
while (1) {
tui_begin_frame(&diary_past_view);
for (i = 0; i < entries_count; i++) {
tui_print("%2d - %s", i + 1, entries[i].date_d ? entries[i].date_d : "(no d-tag)");
}
if (entries_count == 0) {
tui_print("No diary entries found.");
}
tui_print("");
tui_print("Enter number to view, x to back.");
if (entries_count == 0) {
nt_log("No diary entries found.");
diary_free_entries(entries, entries_count);
return;
}
(void)tui_end_frame_with_prompt(">", input, (int)sizeof(input));
if (input[0] == 'x' || input[0] == 'X' || input[0] == 'q' || input[0] == 'Q') {
while (1) {
TuiFrame frame = nt_frame("> Main Menu > Diary > Past Entries");
TuiStatus status = nt_status();
TuiColumn cols[] = {
{"#", 4, 1},
{"Date", 0, 0},
};
diary_table_ctx_t ctx;
TuiTable table;
TuiMenuItem items[] = {
{NT_HK("V", "iew selected"), 'v'},
{NT_HK("X", "Back"), 'x'},
};
TuiMenu menu = {items, 2};
TuiMenuState state = {0};
int row;
int action;
ctx.entries = entries;
ctx.count = entries_count;
table.columns = cols;
table.column_count = (int)(sizeof(cols) / sizeof(cols[0]));
table.row_count = entries_count;
table.user_data = &ctx;
table.get_cell = diary_table_get_cell;
table.is_default = NULL;
table.prefix_len = NULL;
row = tuin_table_run(&frame, &table, &status);
if (row >= 0 && row < entries_count) {
selected = row;
}
action = tuin_menu_run(&frame, &menu, &status, &state);
if (action < 0 || action == 1) {
break;
}
{
int idx = atoi(input) - 1;
if (idx < 0 || idx >= entries_count) {
continue;
}
tui_begin_frame(&diary_entry_view);
tui_print("Date: %s", entries[idx].date_d ? entries[idx].date_d : "");
tui_print("");
tui_print("%s", entries[idx].content ? entries[idx].content : "");
(void)tui_end_frame_with_prompt("Press Enter to exit >", input, (int)sizeof(input));
if (action == 0 && selected >= 0 && selected < entries_count) {
diary_show_entry(&entries[selected]);
}
}
@@ -367,45 +449,43 @@ static void diary_browse_past(void) {
}
void menu_diary(void) {
static const tui_view_t diary_view = {
"> Main Menu > Diary",
NULL,
static const TuiMenuItem DIARY_ITEMS[] = {
{NT_HK("E", "dit today's entry"), 'e'},
{NT_HK("B", "rowse past entries"), 'b'},
{NT_HK("X", "Exit"), 'x'},
};
char today_d[16];
char *existing = NULL;
char *edited = NULL;
char input[16];
TuiMenuState menu_state = {0};
if (diary_get_today_d(today_d) != 0) {
tui_begin_frame(&diary_view);
tui_print("Failed to compute today's date.");
(void)tui_end_frame_with_prompt("Press Enter to exit >", input, (int)sizeof(input));
tuin_notice("Failed to compute today's date.");
return;
}
existing = diary_load_entry_plain(today_d);
while (1) {
tui_begin_frame(&diary_view);
tui_print("Today: %s", today_d);
tui_print("Existing entry: %s", existing ? "yes" : "no");
tui_print_menu_item("^_E^:dit today's entry");
tui_print_menu_item("^_B^:rowse past entries");
tui_print_menu_item("^_X^:Exit");
TuiFrame frame = nt_frame("> Main Menu > Diary");
char status_text[128];
TuiStatus status;
TuiMenu menu = {DIARY_ITEMS, 3};
int idx;
(void)tui_end_frame_with_prompt(">", input, (int)sizeof(input));
snprintf(status_text, sizeof(status_text), "Today: %s | Existing entry: %s", today_d, existing ? "yes" : "no");
status.text = status_text;
if (input[0] == 'q' || input[0] == 'Q' ||
input[0] == 'x' || input[0] == 'X') {
idx = tuin_menu_run(&frame, &menu, &status, &menu_state);
if (idx < 0 || idx == 2) {
break;
}
if (input[0] == 'e' || input[0] == 'E') {
edited = editor_launch(existing ? existing : "");
if (idx == 0) {
char *edited = diary_launch_editor(existing ? existing : "");
if (!edited) {
tui_begin_frame(&diary_view);
tui_print("Edit canceled.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
nt_log("Edit canceled.");
continue;
}
@@ -414,10 +494,8 @@ void menu_diary(void) {
existing = diary_strdup(edited);
}
free(edited);
tui_begin_frame(&diary_view);
tui_print("Diary save attempted.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
} else if (input[0] == 'b' || input[0] == 'B') {
nt_log("Diary save attempted.");
} else if (idx == 1) {
diary_browse_past();
}
}

View File

@@ -10,6 +10,7 @@
#include "../resources/nostr_core_lib/nostr_core/nip019.h"
#include "../resources/nostr_core_lib/nostr_core/utils.h"
#include <ncurses.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -22,6 +23,11 @@ typedef struct {
int kind;
} dm_item_t;
typedef struct {
const dm_item_t *items;
int count;
} dm_table_ctx_t;
static char *dm_strdup(const char *s) {
size_t n;
char *out;
@@ -88,6 +94,64 @@ static const char *dm_shorten_hex(const char *hex, char *buf, size_t buf_sz) {
return buf;
}
static void dm_preview_text(const char *text, char *buf, size_t buf_sz, int max_chars) {
size_t i;
size_t j = 0;
if (!buf || buf_sz == 0U) {
return;
}
buf[0] = '\0';
if (!text || text[0] == '\0' || max_chars <= 0) {
return;
}
for (i = 0; text[i] != '\0' && j + 1U < buf_sz; i++) {
char ch = text[i];
if (ch == '\n' || ch == '\r' || ch == '\t') {
ch = ' ';
}
buf[j++] = ch;
if ((int)j >= max_chars) {
break;
}
}
buf[j] = '\0';
if (text[i] != '\0' && j + 4U < buf_sz) {
buf[j++] = '.';
buf[j++] = '.';
buf[j++] = '.';
buf[j] = '\0';
}
}
static void dm_format_date(long long created_at, char *buf, size_t buf_sz) {
time_t t;
struct tm tmv;
if (!buf || buf_sz == 0U) {
return;
}
if (created_at <= 0) {
snprintf(buf, buf_sz, "-");
return;
}
t = (time_t)created_at;
memset(&tmv, 0, sizeof(tmv));
if (!localtime_r(&t, &tmv)) {
snprintf(buf, buf_sz, "%lld", created_at);
return;
}
if (strftime(buf, buf_sz, "%Y-%m-%d %H:%M", &tmv) == 0) {
snprintf(buf, buf_sz, "%lld", created_at);
}
}
static int dm_push_item(dm_item_t **items, int *count, dm_item_t *src) {
dm_item_t *next;
@@ -133,10 +197,6 @@ static int dm_parse_recipient_hex(const char *input, char out_hex[65]) {
}
static void menu_dm_send(void) {
static const tui_view_t dm_send_view = {
"> Main Menu > DM > Send",
NULL,
};
char who[256];
char msg[4096];
char recip_hex[65] = {0};
@@ -149,30 +209,24 @@ static void menu_dm_send(void) {
int relay_count = 0;
int i;
int any_ok = 0;
char hold[16];
tui_begin_frame(&dm_send_view);
(void)tui_end_frame_with_prompt("recipient (npub or hex) >", who, (int)sizeof(who));
if (who[0] == '\0') {
who[0] = '\0';
if (tuin_prompt("recipient (npub or hex) >", "", who, sizeof(who)) != 0 || who[0] == '\0') {
return;
}
if (dm_parse_recipient_hex(who, recip_hex) != 0) {
tui_begin_frame(&dm_send_view);
tui_print("Invalid recipient. Use npub or 64-char hex pubkey.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", hold, (int)sizeof(hold));
tuin_notice("Invalid recipient. Use npub or 64-char hex pubkey.");
return;
}
(void)tui_end_frame_with_prompt("message >", msg, (int)sizeof(msg));
if (msg[0] == '\0') {
msg[0] = '\0';
if (tuin_prompt("message >", "", msg, sizeof(msg)) != 0 || msg[0] == '\0') {
return;
}
if (signer_get_local_private_key(priv) != 0) {
tui_begin_frame(&dm_send_view);
tui_print("Private key unavailable for DM send.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", hold, (int)sizeof(hold));
tuin_notice("Private key unavailable for DM send.");
return;
}
@@ -185,24 +239,18 @@ static void menu_dm_send(void) {
NULL,
g_state.npub_hex);
if (!rumor) {
tui_begin_frame(&dm_send_view);
tui_print("Failed to build NIP-17 DM rumor.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", hold, (int)sizeof(hold));
tuin_notice("Failed to build NIP-17 DM rumor.");
return;
}
wrap_count = nostr_nip17_send_dm(rumor, recips, 1, priv, gift_wraps, 8, 0);
cJSON_Delete(rumor);
if (wrap_count <= 0) {
tui_begin_frame(&dm_send_view);
tui_print("Failed to create NIP-17 gift wrap event(s).");
(void)tui_end_frame_with_prompt("Press Enter to continue >", hold, (int)sizeof(hold));
tuin_notice("Failed to create NIP-17 gift wrap event(s).");
return;
}
write_relays = state_get_write_relays(&relay_count);
tui_begin_frame(&dm_send_view);
tui_print("Publishing %d wrapped DM event(s) to %d relay(s)...", wrap_count, relay_count);
for (i = 0; i < wrap_count; i++) {
char *event_json;
@@ -227,12 +275,10 @@ static void menu_dm_send(void) {
}
if (any_ok) {
tui_print("DM sent.");
nt_log("DM sent.");
} else {
tui_print("DM publish failed.");
tuin_notice("DM publish failed.");
}
(void)tui_end_frame_with_prompt("Press Enter to continue >", hold, (int)sizeof(hold));
}
static char *dm_try_decrypt_kind4(const char *sender_pub_hex, const char *ciphertext) {
@@ -403,18 +449,183 @@ static void dm_collect_nip17_kind1059(dm_item_t **items, int *items_count) {
free(events);
}
static void dm_table_get_cell(int row, int col, char *out, size_t out_size, void *user_data) {
const dm_table_ctx_t *ctx = (const dm_table_ctx_t *)user_data;
const dm_item_t *it;
char sender_short[32];
char date_buf[64];
if (!out || out_size == 0U) {
return;
}
out[0] = '\0';
if (!ctx || row < 0 || row >= ctx->count) {
return;
}
it = &ctx->items[row];
dm_format_date(it->created_at, date_buf, sizeof(date_buf));
if (col == 0) {
snprintf(out, out_size, "%d", row + 1);
} else if (col == 1) {
snprintf(out, out_size, "%d", it->kind);
} else if (col == 2) {
snprintf(out, out_size, "%s", dm_shorten_hex(it->sender_pub, sender_short, sizeof(sender_short)));
} else if (col == 3) {
char preview[256];
dm_preview_text(it->content, preview, sizeof(preview), 120);
snprintf(out, out_size, "%s", preview);
} else if (col == 4) {
snprintf(out, out_size, "%s", date_buf);
}
}
static int dm_build_detail_lines(const dm_item_t *it, char ***lines_out, int *count_out) {
char **lines = NULL;
int count = 0;
char date_buf[64];
const char *content;
size_t i;
size_t start = 0;
if (!it || !lines_out || !count_out) {
return -1;
}
*lines_out = NULL;
*count_out = 0;
dm_format_date(it->created_at, date_buf, sizeof(date_buf));
#define DM_PUSH_LINE(txt) \
do { \
char **next = (char **)realloc(lines, (size_t)(count + 1) * sizeof(char *)); \
if (!next) { \
goto fail; \
} \
lines = next; \
lines[count] = dm_strdup((txt) ? (txt) : ""); \
if (!lines[count]) { \
goto fail; \
} \
count++; \
} while (0)
{
char hdr[128];
snprintf(hdr, sizeof(hdr), "kind: %d", it->kind);
DM_PUSH_LINE(hdr);
}
{
char sender_short[64];
char hdr[160];
snprintf(hdr,
sizeof(hdr),
"from: %s",
dm_shorten_hex(it->sender_pub, sender_short, sizeof(sender_short)));
DM_PUSH_LINE(hdr);
}
{
char hdr[128];
snprintf(hdr, sizeof(hdr), "created: %s", date_buf);
DM_PUSH_LINE(hdr);
}
DM_PUSH_LINE("");
content = it->content ? it->content : "";
for (i = 0; ; i++) {
if (content[i] == '\n' || content[i] == '\0') {
size_t len = i - start;
char *ln = (char *)malloc(len + 1U);
if (!ln) {
goto fail;
}
memcpy(ln, content + start, len);
ln[len] = '\0';
DM_PUSH_LINE(ln);
free(ln);
if (content[i] == '\0') {
break;
}
start = i + 1U;
}
}
*lines_out = lines;
*count_out = count;
return 0;
fail:
for (i = 0; i < (size_t)count; i++) {
free(lines[i]);
}
free(lines);
return -1;
}
static void dm_free_detail_lines(char **lines, int count) {
int i;
for (i = 0; i < count; i++) {
free(lines[i]);
}
free(lines);
}
static void dm_view_item(const dm_item_t *it) {
char **lines = NULL;
int line_count = 0;
size_t total = 1U;
char *text;
int i;
if (!it) {
return;
}
if (dm_build_detail_lines(it, &lines, &line_count) != 0) {
tuin_notice("Failed to render DM thread view.");
return;
}
for (i = 0; i < line_count; i++) {
total += strlen(lines[i] ? lines[i] : "") + 1U;
}
text = (char *)malloc(total);
if (!text) {
dm_free_detail_lines(lines, line_count);
tuin_notice("Out of memory while preparing DM view.");
return;
}
text[0] = '\0';
for (i = 0; i < line_count; i++) {
strcat(text, lines[i] ? lines[i] : "");
strcat(text, "\n");
}
nt_pager_show_text("> Main Menu > DM > Thread", text);
free(text);
dm_free_detail_lines(lines, line_count);
}
static void menu_dm_read_inbox(void) {
static const tui_view_t dm_inbox_view = {
"> Main Menu > DM > Inbox",
NULL,
};
dm_item_t *items = NULL;
int items_count = 0;
int i;
char hold[16];
tui_begin_frame(&dm_inbox_view);
tui_print("Querying kind 1059 + kind 4...");
dm_table_ctx_t ctx;
TuiColumn cols[] = {
{"#", 4, 1},
{"Kind", 8, 0},
{"From", 16, 0},
{"Message", 0, 0},
{"Date", 18, 0},
};
TuiTable table;
TuiFrame frame = nt_frame("> Main Menu > DM > Inbox");
TuiStatus status;
dm_collect_nip17_kind1059(&items, &items_count);
dm_collect_legacy_kind4(&items, &items_count);
@@ -423,46 +634,59 @@ static void menu_dm_read_inbox(void) {
qsort(items, (size_t)items_count, sizeof(dm_item_t), dm_cmp_desc_created);
}
tui_begin_frame(&dm_inbox_view);
for (i = 0; i < items_count; i++) {
char sender_short[32];
const char *sender = dm_shorten_hex(items[i].sender_pub, sender_short, sizeof(sender_short));
tui_print("[%3d] (%d) %s: %s", i + 1, items[i].kind, sender, items[i].content ? items[i].content : "");
if (items_count == 0) {
nt_log("No DMs found.");
dm_free_items(items, items_count);
return;
}
if (items_count == 0) {
tui_print("No DMs found.");
ctx.items = items;
ctx.count = items_count;
table.columns = cols;
table.column_count = (int)(sizeof(cols) / sizeof(cols[0]));
table.row_count = items_count;
table.user_data = &ctx;
table.get_cell = dm_table_get_cell;
table.is_default = NULL;
table.prefix_len = NULL;
status.text = "Select a message and press Enter";
while (1) {
int row = tuin_table_run(&frame, &table, &status);
if (row < 0) {
break;
}
if (row >= 0 && row < items_count) {
dm_view_item(&items[row]);
}
}
dm_free_items(items, items_count);
(void)tui_end_frame_with_prompt("Press Enter to exit >", hold, (int)sizeof(hold));
}
void menu_dm(void) {
static const tui_view_t dm_view = {
"> Main Menu > DM",
NULL,
static const TuiMenuItem DM_ITEMS[] = {
{NT_HK("S", "end DM (NIP-17)"), 's'},
{NT_HK("R", "ead inbox (kind 1059 + kind 4)"), 'r'},
{NT_HK("X", "Exit"), 'x'},
};
char input[16];
TuiMenuState menu_state = {0};
while (1) {
tui_begin_frame(&dm_view);
tui_print_menu_item("^_S^:end DM (NIP-17)");
tui_print_menu_item("^_R^:ead inbox (kind 1059 + kind 4)");
tui_print_menu_item("^_X^:Exit");
TuiFrame frame = nt_frame("> Main Menu > DM");
TuiStatus status = nt_status();
TuiMenu menu = {DM_ITEMS, (int)(sizeof(DM_ITEMS) / sizeof(DM_ITEMS[0]))};
int idx = tuin_menu_run(&frame, &menu, &status, &menu_state);
(void)tui_end_frame_with_prompt(">", input, (int)sizeof(input));
if (input[0] == 'b' || input[0] == 'B' ||
input[0] == 'x' || input[0] == 'X' ||
input[0] == 'q' || input[0] == 'Q') {
if (idx < 0 || idx == 2) {
break;
}
if (input[0] == 's' || input[0] == 'S') {
if (idx == 0) {
menu_dm_send();
} else if (input[0] == 'r' || input[0] == 'R') {
} else if (idx == 1) {
menu_dm_read_inbox();
}
}

View File

@@ -20,6 +20,10 @@ typedef struct {
int proof_count;
} ecash_wallet_t;
typedef struct {
const ecash_wallet_t *wallet;
} ecash_balance_table_ctx_t;
static char *ecash_strdup(const char *s) {
size_t n;
char *out;
@@ -406,174 +410,216 @@ static uint64_t ecash_wallet_total(const ecash_wallet_t *w) {
return total;
}
static void ecash_print_mint_row_responsive(int term_width, int index, const char *mint_url, uint64_t mint_total) {
if (term_width < 90) {
tui_print("%2d. %s", index, mint_url ? mint_url : "");
tui_print(" %llu sats", (unsigned long long)mint_total);
static uint64_t ecash_mint_total(const ecash_wallet_t *w, int mint_idx) {
int j;
uint64_t mint_total = 0;
if (!w || mint_idx < 0 || mint_idx >= w->mint_count) {
return 0;
}
for (j = 0; j < w->proof_count; j++) {
if (w->proof_mints[j] && w->mints[mint_idx] && strcmp(w->proof_mints[j], w->mints[mint_idx]) == 0) {
mint_total += w->proofs[j].amount;
}
}
return mint_total;
}
static void ecash_balance_get_cell(int row, int col, char *out, size_t out_size, void *user_data) {
const ecash_balance_table_ctx_t *ctx = (const ecash_balance_table_ctx_t *)user_data;
const ecash_wallet_t *w;
if (!out || out_size == 0U) {
return;
}
out[0] = '\0';
if (!ctx || !ctx->wallet) {
return;
}
tui_print("%2d. %-48s %12llu sats",
index,
mint_url ? mint_url : "",
(unsigned long long)mint_total);
w = ctx->wallet;
if (row < 0 || row >= w->mint_count) {
return;
}
if (col == 0) {
snprintf(out, out_size, "%d", row + 1);
} else if (col == 1) {
snprintf(out, out_size, "%s", w->mints[row] ? w->mints[row] : "");
} else if (col == 2) {
snprintf(out, out_size, "%llu", (unsigned long long)ecash_mint_total(w, row));
}
}
static void ecash_show_balance(const ecash_wallet_t *w) {
static const tui_view_t ecash_balance_view = {
"> Main Menu > Ecash > Balance",
NULL,
};
int i;
int term_width = 0;
char hold[16];
TuiFrame frame = nt_frame("> Main Menu > Ecash > Balance");
char status_text[128];
TuiStatus status;
tui_begin_frame(&ecash_balance_view);
tui_get_terminal_size(&term_width, NULL);
tui_print("Total: %llu sats", (unsigned long long)ecash_wallet_total(w));
tui_print("");
snprintf(status_text,
sizeof(status_text),
"Total: %llu sats | Proof count: %d",
(unsigned long long)ecash_wallet_total(w),
w ? w->proof_count : 0);
status.text = status_text;
for (i = 0; i < w->mint_count; i++) {
int j;
uint64_t mint_total = 0;
for (j = 0; j < w->proof_count; j++) {
if (w->proof_mints[j] && w->mints[i] && strcmp(w->proof_mints[j], w->mints[i]) == 0) {
mint_total += w->proofs[j].amount;
}
}
ecash_print_mint_row_responsive(term_width, i + 1, w->mints[i], mint_total);
}
if (w->mint_count == 0) {
tui_print("No mints configured.");
}
tui_print("");
tui_print("Proof count: %d", w->proof_count);
(void)tui_end_frame_with_prompt("Press Enter to exit >", hold, (int)sizeof(hold));
}
static void ecash_add_mint_menu(ecash_wallet_t *w) {
static const tui_view_t ecash_add_mint_view = {
"> Main Menu > Ecash > Add Mint",
NULL,
};
char mint[512];
char hold[16];
tui_begin_frame(&ecash_add_mint_view);
(void)tui_end_frame_with_prompt("mint url >", mint, (int)sizeof(mint));
if (mint[0] == '\0') {
if (!w || w->mint_count == 0) {
tuin_notice("No mints configured.");
return;
}
{
TuiColumn cols[] = {
{"#", 4, 1},
{"Mint", 0, 0},
{"Sats", 12, 1},
};
ecash_balance_table_ctx_t ctx;
TuiTable table;
ctx.wallet = w;
table.columns = cols;
table.column_count = (int)(sizeof(cols) / sizeof(cols[0]));
table.row_count = w->mint_count;
table.user_data = &ctx;
table.get_cell = ecash_balance_get_cell;
table.is_default = NULL;
table.prefix_len = NULL;
(void)tuin_table_run(&frame, &table, &status);
}
}
static int ecash_select_mint_index(const char *breadcrumb, const ecash_wallet_t *w, int *mint_idx_out) {
TuiMenuItem *items;
char (*labels)[512];
TuiMenu menu;
TuiMenuState state = {0};
TuiFrame frame;
TuiStatus status = nt_status();
int i;
int choice;
if (!w || w->mint_count <= 0 || !mint_idx_out) {
return -1;
}
items = (TuiMenuItem *)calloc((size_t)(w->mint_count + 1), sizeof(TuiMenuItem));
labels = (char(*)[512])calloc((size_t)(w->mint_count + 1), sizeof(*labels));
if (!items || !labels) {
free(items);
free(labels);
return -1;
}
for (i = 0; i < w->mint_count; i++) {
snprintf(labels[i], sizeof(labels[i]), "%s", w->mints[i] ? w->mints[i] : "");
items[i].label = labels[i];
items[i].shortcut = 0;
}
snprintf(labels[w->mint_count], sizeof(labels[w->mint_count]), "Back");
items[w->mint_count].label = labels[w->mint_count];
items[w->mint_count].shortcut = 'b';
menu.items = items;
menu.count = w->mint_count + 1;
frame = nt_frame(breadcrumb ? breadcrumb : "> Main Menu > Ecash > Select Mint");
choice = tuin_menu_run(&frame, &menu, &status, &state);
free(items);
free(labels);
if (choice < 0 || choice == w->mint_count) {
return -1;
}
*mint_idx_out = choice;
return 0;
}
static void ecash_add_mint_menu(ecash_wallet_t *w) {
char mint[512];
mint[0] = '\0';
if (tuin_prompt("mint url >", "", mint, sizeof(mint)) != 0 || mint[0] == '\0') {
return;
}
tui_begin_frame(&ecash_add_mint_view);
if (ecash_add_mint(w, mint) != 0) {
tui_print("Failed to add mint.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", hold, (int)sizeof(hold));
tuin_notice("Failed to add mint.");
return;
}
if (ecash_publish_wallet(w) != 0) {
tui_print("Failed to publish wallet metadata.");
tuin_notice("Failed to publish wallet metadata.");
} else {
tui_print("Mint added.");
nt_log("Mint added.");
}
(void)tui_end_frame_with_prompt("Press Enter to continue >", hold, (int)sizeof(hold));
}
static void ecash_remove_mint_menu(ecash_wallet_t *w) {
static const tui_view_t ecash_remove_mint_view = {
"> Main Menu > Ecash > Remove Mint",
NULL,
};
char input[64];
char yn[8];
int idx;
int i;
char hold[16];
if (!w || w->mint_count <= 0) {
tui_begin_frame(&ecash_remove_mint_view);
tui_print("No mints to remove.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", hold, (int)sizeof(hold));
tuin_notice("No mints to remove.");
return;
}
tui_begin_frame(&ecash_remove_mint_view);
for (i = 0; i < w->mint_count; i++) {
tui_print("%2d. %s", i + 1, w->mints[i]);
}
(void)tui_end_frame_with_prompt("remove number >", input, (int)sizeof(input));
idx = atoi(input) - 1;
if (idx < 0 || idx >= w->mint_count) {
tui_begin_frame(&ecash_remove_mint_view);
tui_print("Invalid mint number.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", hold, (int)sizeof(hold));
if (ecash_select_mint_index("> Main Menu > Ecash > Remove Mint", w, &idx) != 0) {
return;
}
tui_begin_frame(&ecash_remove_mint_view);
tui_print("Remove mint: %s", w->mints[idx] ? w->mints[idx] : "");
(void)tui_end_frame_with_prompt("Confirm remove [y/n] >", yn, (int)sizeof(yn));
if (!(yn[0] == 'y' || yn[0] == 'Y')) {
tui_begin_frame(&ecash_remove_mint_view);
tui_print("Remove canceled.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", hold, (int)sizeof(hold));
if (tuin_confirm("Confirm remove selected mint?") != 1) {
nt_log("Remove canceled.");
return;
}
free(w->mints[idx]);
for (i = idx; i < w->mint_count - 1; i++) {
w->mints[i] = w->mints[i + 1];
for (; idx < w->mint_count - 1; idx++) {
w->mints[idx] = w->mints[idx + 1];
}
w->mint_count--;
tui_begin_frame(&ecash_remove_mint_view);
if (ecash_publish_wallet(w) != 0) {
tui_print("Failed to publish wallet metadata.");
tuin_notice("Failed to publish wallet metadata.");
} else {
tui_print("Mint removed.");
nt_log("Mint removed.");
}
(void)tui_end_frame_with_prompt("Press Enter to continue >", hold, (int)sizeof(hold));
}
static void ecash_receive_token_menu(ecash_wallet_t *w) {
static const tui_view_t ecash_import_view = {
"> Main Menu > Ecash > Import Token",
NULL,
};
char token[8192];
cashu_decoded_token_t decoded;
int i;
char hold[16];
if (!w) {
return;
}
memset(&decoded, 0, sizeof(decoded));
tui_begin_frame(&ecash_import_view);
(void)tui_end_frame_with_prompt("paste cashu token >", token, (int)sizeof(token));
if (token[0] == '\0') {
token[0] = '\0';
if (tuin_prompt("paste cashu token >", "", token, sizeof(token)) != 0 || token[0] == '\0') {
return;
}
if (tuin_confirm("Import token into wallet?") != 1) {
return;
}
if (cashu_decode_token(token, &decoded) != 0) {
tui_begin_frame(&ecash_import_view);
tui_print("Failed to decode token.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", hold, (int)sizeof(hold));
tuin_notice("Failed to decode token.");
return;
}
if (!decoded.mint_url || decoded.proof_count <= 0 || !decoded.proofs) {
cashu_free_decoded_token(&decoded);
tui_begin_frame(&ecash_import_view);
tui_print("Token did not contain usable proofs.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", hold, (int)sizeof(hold));
tuin_notice("Token did not contain usable proofs.");
return;
}
@@ -584,13 +630,11 @@ static void ecash_receive_token_menu(ecash_wallet_t *w) {
cashu_free_decoded_token(&decoded);
tui_begin_frame(&ecash_import_view);
if (ecash_publish_wallet(w) != 0) {
tui_print("Token imported locally, but publish failed.");
tuin_notice("Token imported locally, but publish failed.");
} else {
tui_print("Token received and wallet updated.");
nt_log("Token received and wallet updated.");
}
(void)tui_end_frame_with_prompt("Press Enter to continue >", hold, (int)sizeof(hold));
}
static void ecash_remove_proofs_by_global_indices(ecash_wallet_t *w, const int *indices, int nidx) {
@@ -633,16 +677,8 @@ static void ecash_remove_proofs_by_global_indices(ecash_wallet_t *w, const int *
}
static void ecash_send_token_menu(ecash_wallet_t *w) {
static const tui_view_t ecash_send_view = {
"> Main Menu > Ecash > Send Token",
NULL,
};
static const tui_view_t ecash_send_result_view = {
"> Main Menu > Ecash > Send Token > Result",
NULL,
};
char input[128];
int mint_idx;
char input[128];
uint64_t target_amount;
int i;
nostr_cashu_proof_t *mint_proofs = NULL;
@@ -654,41 +690,32 @@ static void ecash_send_token_menu(ecash_wallet_t *w) {
cashu_decoded_token_t token;
char *encoded = NULL;
int *selected_global = NULL;
char hold[16];
char result[1024];
if (!w) {
return;
}
if (w->mint_count <= 0) {
tui_begin_frame(&ecash_send_view);
tui_print("No mints configured.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", hold, (int)sizeof(hold));
tuin_notice("No mints configured.");
return;
}
tui_begin_frame(&ecash_send_view);
for (i = 0; i < w->mint_count; i++) {
tui_print("%2d. %s", i + 1, w->mints[i]);
}
(void)tui_end_frame_with_prompt("mint number >", input, (int)sizeof(input));
mint_idx = atoi(input) - 1;
if (mint_idx < 0 || mint_idx >= w->mint_count) {
tui_begin_frame(&ecash_send_view);
tui_print("Invalid mint number.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", hold, (int)sizeof(hold));
if (ecash_select_mint_index("> Main Menu > Ecash > Send Token", w, &mint_idx) != 0) {
return;
}
tui_begin_frame(&ecash_send_view);
tui_print("Mint: %s", w->mints[mint_idx]);
(void)tui_end_frame_with_prompt("amount (sats) >", input, (int)sizeof(input));
input[0] = '\0';
if (tuin_prompt("amount (sats) >", "", input, sizeof(input)) != 0) {
return;
}
target_amount = (uint64_t)strtoull(input, NULL, 10);
if (target_amount == 0) {
tui_begin_frame(&ecash_send_view);
tui_print("Invalid amount.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", hold, (int)sizeof(hold));
tuin_notice("Invalid amount.");
return;
}
if (tuin_confirm("Create and remove proofs for outgoing token?") != 1) {
return;
}
@@ -701,9 +728,7 @@ static void ecash_send_token_menu(ecash_wallet_t *w) {
if (!next_proofs) {
free(mint_proofs);
free(mint_global_indices);
tui_begin_frame(&ecash_send_view);
tui_print("Memory error.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", hold, (int)sizeof(hold));
tuin_notice("Memory error.");
return;
}
@@ -713,9 +738,7 @@ static void ecash_send_token_menu(ecash_wallet_t *w) {
if (!next_idx) {
free(mint_proofs);
free(mint_global_indices);
tui_begin_frame(&ecash_send_view);
tui_print("Memory error.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", hold, (int)sizeof(hold));
tuin_notice("Memory error.");
return;
}
@@ -729,9 +752,7 @@ static void ecash_send_token_menu(ecash_wallet_t *w) {
if (mint_proof_count <= 0) {
free(mint_proofs);
free(mint_global_indices);
tui_begin_frame(&ecash_send_view);
tui_print("No proofs available for selected mint.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", hold, (int)sizeof(hold));
tuin_notice("No proofs available for selected mint.");
return;
}
@@ -739,9 +760,7 @@ static void ecash_send_token_menu(ecash_wallet_t *w) {
if (!selected_local) {
free(mint_proofs);
free(mint_global_indices);
tui_begin_frame(&ecash_send_view);
tui_print("Memory error.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", hold, (int)sizeof(hold));
tuin_notice("Memory error.");
return;
}
@@ -755,9 +774,7 @@ static void ecash_send_token_menu(ecash_wallet_t *w) {
free(selected_local);
free(mint_proofs);
free(mint_global_indices);
tui_begin_frame(&ecash_send_view);
tui_print("Could not select proofs for that amount.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", hold, (int)sizeof(hold));
tuin_notice("Could not select proofs for that amount.");
return;
}
@@ -773,9 +790,7 @@ static void ecash_send_token_menu(ecash_wallet_t *w) {
free(mint_global_indices);
free(selected_global);
cashu_free_decoded_token(&token);
tui_begin_frame(&ecash_send_view);
tui_print("Memory error.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", hold, (int)sizeof(hold));
tuin_notice("Memory error.");
return;
}
@@ -790,9 +805,7 @@ static void ecash_send_token_menu(ecash_wallet_t *w) {
free(mint_global_indices);
free(selected_global);
cashu_free_decoded_token(&token);
tui_begin_frame(&ecash_send_view);
tui_print("Failed to assemble token proofs.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", hold, (int)sizeof(hold));
tuin_notice("Failed to assemble token proofs.");
return;
}
@@ -806,23 +819,21 @@ static void ecash_send_token_menu(ecash_wallet_t *w) {
free(mint_global_indices);
free(selected_global);
cashu_free_decoded_token(&token);
tui_begin_frame(&ecash_send_view);
tui_print("Failed to encode token.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", hold, (int)sizeof(hold));
tuin_notice("Failed to encode token.");
return;
}
ecash_remove_proofs_by_global_indices(w, selected_global, selected_count);
(void)ecash_publish_wallet(w);
tui_begin_frame(&ecash_send_result_view);
tui_print("Requested: %llu sats", (unsigned long long)target_amount);
tui_print("Selected: %llu sats", (unsigned long long)selected_total);
tui_print("Token:");
tui_print("%s", encoded);
tui_print("");
tui_print("Note: Mint HTTP swap/melt flows are not yet implemented in this MVP.");
(void)tui_end_frame_with_prompt("Press Enter to exit >", hold, (int)sizeof(hold));
snprintf(result,
sizeof(result),
"Requested: %llu sats\nSelected: %llu sats\n\nToken:\n%s\n\nNote: Mint HTTP swap/melt flows are not yet implemented in this MVP.",
(unsigned long long)target_amount,
(unsigned long long)selected_total,
encoded);
nt_pager_show_text("> Main Menu > Ecash > Send Token", result);
nt_log("Token created and selected proofs removed from wallet.");
free(encoded);
free(selected_local);
@@ -833,44 +844,52 @@ static void ecash_send_token_menu(ecash_wallet_t *w) {
}
void menu_ecash(void) {
static const tui_view_t ecash_view = {
"> Main Menu > Ecash",
NULL,
static const TuiMenuItem ECASH_ITEMS[] = {
{NT_HK("B", "alance"), 'b'},
{NT_HK("A", "dd mint"), 'a'},
{NT_HK("R", "emove mint"), 'r'},
{NT_HK("I", "mport token"), 'i'},
{NT_HK("S", "end token"), 's'},
{"E" NT_HK("x", "it"), 'x'},
};
ecash_wallet_t wallet;
char input[16];
TuiMenuState menu_state = {0};
if (ecash_wallet_load(&wallet) != 0) {
memset(&wallet, 0, sizeof(wallet));
}
while (1) {
tui_begin_frame(&ecash_view);
tui_print("Mints: %d", wallet.mint_count);
tui_print("Proofs: %d", wallet.proof_count);
tui_print_menu_item("^_B^:alance");
tui_print_menu_item("^_A^:dd mint");
tui_print_menu_item("^_R^:emove mint");
tui_print_menu_item("^_I^:mport token");
tui_print_menu_item("^_S^:end token");
tui_print_menu_item("E^_x^:it");
TuiFrame frame = nt_frame("> Main Menu > Ecash");
char status_text[128];
TuiStatus status;
TuiMenu menu = {ECASH_ITEMS, 6};
int idx;
(void)tui_end_frame_with_prompt(">", input, (int)sizeof(input));
snprintf(status_text,
sizeof(status_text),
"Mints: %d | Proofs: %d | Total: %llu sats",
wallet.mint_count,
wallet.proof_count,
(unsigned long long)ecash_wallet_total(&wallet));
status.text = status_text;
if (input[0] == 'x' || input[0] == 'X' ||
input[0] == 'q' || input[0] == 'Q') {
idx = tuin_menu_run(&frame, &menu, &status, &menu_state);
if (idx < 0 || idx == 5) {
break;
}
if (input[0] == 'b' || input[0] == 'B') {
if (idx == 0) {
ecash_show_balance(&wallet);
} else if (input[0] == 'a' || input[0] == 'A') {
} else if (idx == 1) {
ecash_add_mint_menu(&wallet);
} else if (input[0] == 'r' || input[0] == 'R') {
} else if (idx == 2) {
ecash_remove_mint_menu(&wallet);
} else if (input[0] == 'i' || input[0] == 'I') {
} else if (idx == 3) {
ecash_receive_token_menu(&wallet);
} else if (input[0] == 's' || input[0] == 'S') {
} else if (idx == 4) {
ecash_send_token_menu(&wallet);
}
}

View File

@@ -12,6 +12,12 @@
#include <stdlib.h>
#include <string.h>
typedef struct {
cJSON *tags;
int *tag_indices;
int count;
} follows_table_ctx_t;
static int follows_publish_kind3(cJSON *event_obj) {
cJSON *tags;
cJSON *content;
@@ -30,26 +36,123 @@ static int follows_publish_kind3(cJSON *event_obj) {
8000);
}
static void follows_print_row_responsive(int term_width, int index, const char *npub, const char *name) {
if (term_width < 90) {
tui_print("[%2d] %s", index, npub ? npub : "");
if (name && name[0] != '\0') {
tui_print(" %s", name);
static int follows_collect_rows(cJSON *tags, int **out_indices) {
int n;
int i;
int count = 0;
int *indices;
if (!out_indices || !cJSON_IsArray(tags)) {
return 0;
}
*out_indices = NULL;
n = cJSON_GetArraySize(tags);
if (n <= 0) {
return 0;
}
indices = (int *)malloc((size_t)n * sizeof(int));
if (!indices) {
return 0;
}
for (i = 0; i < n; i++) {
cJSON *tag = cJSON_GetArrayItem(tags, i);
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
if (!cJSON_IsArray(tag) || !cJSON_IsString(t0) || strcmp(t0->valuestring, "p") != 0) {
continue;
}
indices[count++] = i;
}
*out_indices = indices;
return count;
}
static void follows_table_get_cell(int row, int col, char *out, size_t out_size, void *user_data) {
const follows_table_ctx_t *ctx = (const follows_table_ctx_t *)user_data;
cJSON *tag;
cJSON *t1;
cJSON *t3;
if (!out || out_size == 0U) {
return;
}
out[0] = '\0';
if (!ctx || row < 0 || row >= ctx->count) {
return;
}
tui_print("[%2d] %s %s", index, npub ? npub : "", name ? name : "");
tag = cJSON_GetArrayItem(ctx->tags, ctx->tag_indices[row]);
t1 = cJSON_GetArrayItem(tag, 1);
t3 = cJSON_GetArrayItem(tag, 3);
if (col == 0) {
snprintf(out, out_size, "%d", row + 1);
} else if (col == 1) {
char npub[128] = {0};
if (cJSON_IsString(t1) && t1->valuestring) {
unsigned char pub[32];
if (strlen(t1->valuestring) == 64 && nostr_hex_to_bytes(t1->valuestring, pub, 32) == 0) {
if (nostr_key_to_bech32(pub, "npub", npub) != 0) {
snprintf(npub, sizeof(npub), "%s", t1->valuestring);
}
} else {
snprintf(npub, sizeof(npub), "%s", t1->valuestring);
}
}
snprintf(out, out_size, "%s", npub);
} else if (col == 2) {
snprintf(out, out_size, "%s", (cJSON_IsString(t3) && t3->valuestring) ? t3->valuestring : "");
}
}
static void follows_show_profile(cJSON *meta) {
char *meta_json;
size_t total;
char *view_text;
if (!meta) {
nt_pager_show_text("> Main Menu > Follows > Profile", "No metadata content.");
return;
}
meta_json = cJSON_Print(meta);
if (!meta_json) {
tuin_notice("Failed to render profile metadata.");
return;
}
total = strlen("Profile\n\n") + strlen(meta_json) + 1U;
view_text = (char *)malloc(total);
if (!view_text) {
free(meta_json);
tuin_notice("Out of memory while preparing profile view.");
return;
}
snprintf(view_text, total, "Profile\n\n%s", meta_json);
nt_pager_show_text("> Main Menu > Follows > Profile", view_text);
free(view_text);
free(meta_json);
}
void menu_follows(void) {
static const tui_view_t follows_view = {
"> Main Menu > Follows",
NULL,
static const TuiMenuItem ACTION_ITEMS[] = {
{NT_HK("R", "efresh selected profile"), 'r'},
{NT_HK("A", "dd follow"), 'a'},
{NT_HK("D", "elete selected follow"), 'd'},
{NT_HK("P", "ost changes and exit"), 'p'},
{"E" NT_HK("x", "it without saving"), 'x'},
};
cJSON *working = NULL;
cJSON *tags = NULL;
char input[256];
TuiMenuState action_state = {0};
int selected_tag_index = -1;
working = g_state.kind3_json ? cJSON_Parse(g_state.kind3_json) : NULL;
if (!working) {
@@ -67,54 +170,47 @@ void menu_follows(void) {
}
while (1) {
int i;
int n;
int term_width = 0;
int *row_indices = NULL;
int row_count = follows_collect_rows(tags, &row_indices);
follows_table_ctx_t ctx;
TuiColumn cols[] = {
{"#", 4, 1},
{"npub", 0, 0},
{"name", 24, 0},
};
TuiTable table;
TuiFrame frame = nt_frame("> Main Menu > Follows");
TuiStatus status = nt_status();
TuiMenu menu = {ACTION_ITEMS, (int)(sizeof(ACTION_ITEMS) / sizeof(ACTION_ITEMS[0]))};
int selected_row;
int action;
tui_begin_frame(&follows_view);
tui_get_terminal_size(&term_width, NULL);
ctx.tags = tags;
ctx.tag_indices = row_indices;
ctx.count = row_count;
n = cJSON_GetArraySize(tags);
for (i = 0; i < n; i++) {
cJSON *tag = cJSON_GetArrayItem(tags, i);
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
cJSON *t3 = cJSON_GetArrayItem(tag, 3);
char npub[128] = {0};
table.columns = cols;
table.column_count = (int)(sizeof(cols) / sizeof(cols[0]));
table.row_count = row_count;
table.user_data = &ctx;
table.get_cell = follows_table_get_cell;
table.is_default = NULL;
table.prefix_len = NULL;
if (!cJSON_IsArray(tag) || !cJSON_IsString(t0) || strcmp(t0->valuestring, "p") != 0) {
continue;
}
if (cJSON_IsString(t1) && t1->valuestring) {
unsigned char pub[32];
if (strlen(t1->valuestring) == 64 && nostr_hex_to_bytes(t1->valuestring, pub, 32) == 0) {
if (nostr_key_to_bech32(pub, "npub", npub) != 0) {
snprintf(npub, sizeof(npub), "%s", t1->valuestring);
}
} else {
snprintf(npub, sizeof(npub), "%s", t1->valuestring);
}
}
follows_print_row_responsive(term_width,
i + 1,
npub,
(cJSON_IsString(t3) && t3->valuestring) ? t3->valuestring : "");
selected_row = tuin_table_run(&frame, &table, &status);
if (selected_row >= 0 && selected_row < row_count) {
selected_tag_index = row_indices[selected_row];
}
tui_print("");
tui_print_menu_item("^_R^:efresh profile");
tui_print_menu_item("^_A^:dd follow");
tui_print_menu_item("^_D^:elete follow");
tui_print_menu_item("^_P^:ost changes and exit.");
tui_print_menu_item("E^_x^:it without saving");
free(row_indices);
(void)tui_end_frame_with_prompt(">", input, (int)sizeof(input));
action = tuin_menu_run(&frame, &menu, &status, &action_state);
if (action < 0 || action == 4) {
break;
}
if (input[0] == 'a' || input[0] == 'A') {
if (action == 1) {
char who[256];
char yn[32];
char follow_hex[65] = {0};
char filter[256];
char *follow_kind0 = NULL;
@@ -123,35 +219,28 @@ void menu_follows(void) {
cJSON *meta;
cJSON *name;
tui_begin_frame(&follows_view);
(void)tui_end_frame_with_prompt("npub to add >", who, (int)sizeof(who));
if (who[0] == '\0') {
who[0] = '\0';
if (tuin_prompt("npub to add >", "", who, sizeof(who)) != 0 || who[0] == '\0') {
continue;
}
if (strncmp(who, "npub", 4) == 0) {
unsigned char pub[32];
if (nostr_decode_npub(who, pub) != 0) {
tui_begin_frame(&follows_view);
tui_print("Invalid npub.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
tuin_notice("Invalid npub.");
continue;
}
nostr_bytes_to_hex(pub, 32, follow_hex);
} else if (strlen(who) == 64) {
unsigned char pub[32];
if (nostr_hex_to_bytes(who, pub, 32) != 0) {
tui_begin_frame(&follows_view);
tui_print("Invalid hex pubkey.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
tuin_notice("Invalid hex pubkey.");
continue;
}
strncpy(follow_hex, who, sizeof(follow_hex) - 1U);
follow_hex[sizeof(follow_hex) - 1U] = '\0';
} else {
tui_begin_frame(&follows_view);
tui_print("Enter npub or 64-char hex pubkey.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
tuin_notice("Enter npub or 64-char hex pubkey.");
continue;
}
@@ -161,9 +250,7 @@ void menu_follows(void) {
g_state.read_relay_count > 0 ? g_state.read_relay_count : g_state.bootstrap_relay_count,
7000,
&follow_kind0) != 0 || !follow_kind0) {
tui_begin_frame(&follows_view);
tui_print("Can't find user. Try adding more relays.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
tuin_notice("Can't find user. Try adding more relays.");
continue;
}
@@ -180,13 +267,9 @@ void menu_follows(void) {
}
}
tui_begin_frame(&follows_view);
(void)tui_end_frame_with_prompt((cJSON_IsString(name) && name->valuestring)
? "Add follow [y/n] >"
: "Add this follow [y/n] >",
yn,
(int)sizeof(yn));
if (yn[0] == 'y' || yn[0] == 'Y') {
if (tuin_confirm((cJSON_IsString(name) && name->valuestring)
? "Add follow?"
: "Add this follow?") == 1) {
cJSON *tag = cJSON_CreateArray();
cJSON_AddItemToArray(tag, cJSON_CreateString("p"));
cJSON_AddItemToArray(tag, cJSON_CreateString(follow_hex));
@@ -198,25 +281,17 @@ void menu_follows(void) {
cJSON_Delete(meta);
cJSON_Delete(ev);
free(follow_kind0);
} else if (input[0] == 'd' || input[0] == 'D') {
char numbuf[32];
char yn[32];
int idx;
tui_begin_frame(&follows_view);
(void)tui_end_frame_with_prompt("# to delete >", numbuf, (int)sizeof(numbuf));
idx = atoi(numbuf) - 1;
if (idx < 0 || idx >= cJSON_GetArraySize(tags)) {
} else if (action == 2) {
if (selected_tag_index < 0 || selected_tag_index >= cJSON_GetArraySize(tags)) {
tuin_notice("Select a follow row first.");
continue;
}
(void)tui_end_frame_with_prompt("Delete follow [y/n] >", yn, (int)sizeof(yn));
if (yn[0] == 'y' || yn[0] == 'Y') {
cJSON_DeleteItemFromArray(tags, idx);
if (tuin_confirm("Delete follow?") == 1) {
cJSON_DeleteItemFromArray(tags, selected_tag_index);
selected_tag_index = -1;
}
} else if (input[0] == 'r' || input[0] == 'R') {
char numbuf[32];
int idx;
} else if (action == 0) {
cJSON *tag;
cJSON *pub;
char filter[256];
@@ -225,14 +300,12 @@ void menu_follows(void) {
cJSON *content;
cJSON *meta;
tui_begin_frame(&follows_view);
(void)tui_end_frame_with_prompt("# >", numbuf, (int)sizeof(numbuf));
idx = atoi(numbuf) - 1;
if (idx < 0 || idx >= cJSON_GetArraySize(tags)) {
if (selected_tag_index < 0 || selected_tag_index >= cJSON_GetArraySize(tags)) {
tuin_notice("Select a follow row first.");
continue;
}
tag = cJSON_GetArrayItem(tags, idx);
tag = cJSON_GetArrayItem(tags, selected_tag_index);
pub = cJSON_GetArrayItem(tag, 1);
if (!cJSON_IsString(pub) || !pub->valuestring) {
continue;
@@ -244,9 +317,7 @@ void menu_follows(void) {
g_state.read_relay_count > 0 ? g_state.read_relay_count : g_state.bootstrap_relay_count,
7000,
&follow_kind0) != 0 || !follow_kind0) {
tui_begin_frame(&follows_view);
tui_print("Unable to fetch profile.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
tuin_notice("Unable to fetch profile.");
continue;
}
@@ -259,24 +330,11 @@ void menu_follows(void) {
}
}
tui_begin_frame(&follows_view);
tui_print("Profile");
if (meta) {
cJSON *it = NULL;
cJSON_ArrayForEach(it, meta) {
if (cJSON_IsString(it) && it->valuestring) {
tui_print("%s: %s", it->string ? it->string : "field", it->valuestring);
}
}
} else {
tui_print("No metadata content.");
}
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
follows_show_profile(meta);
cJSON_Delete(meta);
cJSON_Delete(ev);
free(follow_kind0);
} else if (input[0] == 'p' || input[0] == 'P') {
} else if (action == 3) {
int posted = follows_publish_kind3(working);
char *new_json = cJSON_PrintUnformatted(working);
if (new_json) {
@@ -284,16 +342,11 @@ void menu_follows(void) {
g_state.kind3_json = new_json;
}
tui_begin_frame(&follows_view);
if (posted < 0) {
tui_print("Follows publish failed.");
tuin_notice("Follows publish failed.");
} else {
tui_print("Follows published.");
nt_log("Follows published.");
}
(void)tui_end_frame_with_prompt("Press Enter to exit >", input, (int)sizeof(input));
break;
} else if (input[0] == 'x' || input[0] == 'X' ||
input[0] == 'q' || input[0] == 'Q') {
break;
}
}

View File

@@ -4,13 +4,23 @@
#include "../resources/nostr_core_lib/cjson/cJSON.h"
#include <ncurses.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
typedef struct {
int term_width;
} live_feed_ctx_t;
char *id;
long long created_at;
char *pubkey;
char *content;
} live_event_t;
typedef struct {
live_event_t *items;
int count;
} live_buffer_t;
static char *live_strdup(const char *s) {
size_t n;
@@ -84,49 +94,6 @@ static void live_preview_text(const char *text, int max_len, char *buf, size_t b
}
}
static void live_event_print_cb(const char *event_json, void *userdata) {
cJSON *ev;
cJSON *pubkey_item;
cJSON *content_item;
const char *pubkey = NULL;
const char *content = "";
char pub_short[32];
char preview[1024];
int max_preview = 64;
live_feed_ctx_t *ctx = (live_feed_ctx_t *)userdata;
if (!event_json) {
return;
}
ev = cJSON_Parse(event_json);
if (!ev) {
return;
}
pubkey_item = cJSON_GetObjectItemCaseSensitive(ev, "pubkey");
content_item = cJSON_GetObjectItemCaseSensitive(ev, "content");
if (cJSON_IsString(pubkey_item) && pubkey_item->valuestring) {
pubkey = pubkey_item->valuestring;
}
if (cJSON_IsString(content_item) && content_item->valuestring) {
content = content_item->valuestring;
}
if (ctx && ctx->term_width > 24) {
max_preview = ctx->term_width - 22;
}
if (max_preview > (int)sizeof(preview) - 1) {
max_preview = (int)sizeof(preview) - 1;
}
live_preview_text(content, max_preview, preview, sizeof(preview));
tui_print("%s: %s", live_shorten_hex(pubkey, pub_short, sizeof(pub_short)), preview);
cJSON_Delete(ev);
}
static int live_extract_follow_authors(char ***authors_out, int *count_out) {
cJSON *kind3;
cJSON *tags;
@@ -217,63 +184,264 @@ static void live_free_authors(char **authors, int count) {
free(authors);
}
static void menu_live_run_filter(const char *title, const char *filter_json) {
static const tui_view_t live_feed_view = {
"> Main Menu > Live Feeds > Stream",
NULL,
};
const char **read_relays;
int relay_count = 0;
live_feed_ctx_t ctx;
int events_received;
char input[8];
static void live_free_buffer(live_buffer_t *buf) {
int i;
read_relays = state_get_read_relays(&relay_count);
tui_begin_frame(&live_feed_view);
tui_print("%s", title ? title : "Live Feed");
tui_print("Press any key to stop...");
memset(&ctx, 0, sizeof(ctx));
tui_get_terminal_size(&ctx.term_width, NULL);
events_received = nt_live_feed(filter_json,
read_relays,
relay_count,
live_event_print_cb,
&ctx);
tui_begin_frame(&live_feed_view);
if (events_received < 0) {
tui_print("Feed stopped (failed to connect to all relays).");
} else {
tui_print("Feed stopped. %d events received.", events_received);
if (!buf || !buf->items) {
return;
}
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
for (i = 0; i < buf->count; i++) {
free(buf->items[i].id);
free(buf->items[i].pubkey);
free(buf->items[i].content);
}
free(buf->items);
buf->items = NULL;
buf->count = 0;
}
void menu_live(void) {
static const tui_view_t live_menu_view = {
"> Main Menu > Live Feeds",
NULL,
};
char input[16];
static int live_event_exists(const live_buffer_t *buf, const char *id) {
int i;
while (1) {
tui_begin_frame(&live_menu_view);
tui_print_menu_item("^_F^:ollows feed");
tui_print_menu_item("^_M^:entions");
tui_print_menu_item("^_G^:lobal firehose");
tui_print_menu_item("^_X^:Exit");
if (!buf || !id) {
return 0;
}
(void)tui_end_frame_with_prompt(">", input, (int)sizeof(input));
for (i = 0; i < buf->count; i++) {
if (buf->items[i].id && strcmp(buf->items[i].id, id) == 0) {
return 1;
}
}
return 0;
}
if (input[0] == 'b' || input[0] == 'B' ||
input[0] == 'x' || input[0] == 'X' ||
input[0] == 'q' || input[0] == 'Q') {
static int live_cmp_desc_created(const void *a, const void *b) {
const live_event_t *ia = (const live_event_t *)a;
const live_event_t *ib = (const live_event_t *)b;
if (ia->created_at < ib->created_at) {
return 1;
}
if (ia->created_at > ib->created_at) {
return -1;
}
return 0;
}
static void live_append_event_json(live_buffer_t *buf, const char *event_json) {
cJSON *ev;
cJSON *id_item;
cJSON *created_item;
cJSON *pubkey_item;
cJSON *content_item;
live_event_t it;
live_event_t *next;
if (!buf || !event_json) {
return;
}
ev = cJSON_Parse(event_json);
if (!ev) {
return;
}
id_item = cJSON_GetObjectItemCaseSensitive(ev, "id");
created_item = cJSON_GetObjectItemCaseSensitive(ev, "created_at");
pubkey_item = cJSON_GetObjectItemCaseSensitive(ev, "pubkey");
content_item = cJSON_GetObjectItemCaseSensitive(ev, "content");
if (!cJSON_IsString(id_item) || !id_item->valuestring ||
!cJSON_IsNumber(created_item) ||
!cJSON_IsString(pubkey_item) || !pubkey_item->valuestring) {
cJSON_Delete(ev);
return;
}
if (live_event_exists(buf, id_item->valuestring)) {
cJSON_Delete(ev);
return;
}
memset(&it, 0, sizeof(it));
it.id = live_strdup(id_item->valuestring);
it.created_at = (long long)created_item->valuedouble;
it.pubkey = live_strdup(pubkey_item->valuestring);
it.content = (cJSON_IsString(content_item) && content_item->valuestring)
? live_strdup(content_item->valuestring)
: live_strdup("");
if (!it.id || !it.pubkey || !it.content) {
free(it.id);
free(it.pubkey);
free(it.content);
cJSON_Delete(ev);
return;
}
next = (live_event_t *)realloc(buf->items, (size_t)(buf->count + 1) * sizeof(live_event_t));
if (!next) {
free(it.id);
free(it.pubkey);
free(it.content);
cJSON_Delete(ev);
return;
}
buf->items = next;
buf->items[buf->count++] = it;
if (buf->count > 1) {
qsort(buf->items, (size_t)buf->count, sizeof(live_event_t), live_cmp_desc_created);
}
cJSON_Delete(ev);
}
static void live_poll_once(const char *filter_json, live_buffer_t *buf) {
const char **read_relays;
int relay_count = 0;
char **events = NULL;
int event_count = 0;
int i;
if (!filter_json || !buf) {
return;
}
read_relays = state_get_read_relays(&relay_count);
if (nt_query_sync(filter_json, read_relays, relay_count, 1200, &events, &event_count) != 0) {
return;
}
for (i = 0; i < event_count; i++) {
live_append_event_json(buf, events[i]);
free(events[i]);
}
free(events);
}
static void menu_live_run_filter(const char *title, const char *filter_json) {
TuiFrame frame = nt_frame("> Main Menu > Live Feeds > Stream");
TuiStatus status;
WINDOW *body;
live_buffer_t buffer;
int top = 0;
int ticks = 0;
if (!filter_json) {
return;
}
memset(&buffer, 0, sizeof(buffer));
status.text = "Esc exit • ↑↓ PgUp/PgDn scroll";
body = (WINDOW *)tuin_body_window();
wtimeout(body, 100);
for (;;) {
int k;
int h;
int w;
int i;
if ((ticks % 8) == 0) {
live_poll_once(filter_json, &buffer);
}
ticks++;
tuin_render_header(&frame);
werase(body);
getmaxyx(body, h, w);
if (title && title[0] != '\0') {
mvwprintw(body, 0, 0, "%.*s", (w > 1) ? (w - 1) : 0, title);
}
if (buffer.count == 0) {
mvwprintw(body, 2, 0, "%.*s", (w > 1) ? (w - 1) : 0, "Waiting for events...");
} else {
int row = 2;
int max_rows = h - row;
for (i = 0; i < max_rows && (top + i) < buffer.count; i++) {
char pub_short[32];
char preview[1024];
int max_preview = (w > 24) ? (w - 22) : 8;
live_event_t *ev = &buffer.items[top + i];
live_preview_text(ev->content, max_preview, preview, sizeof(preview));
mvwprintw(body,
row + i,
0,
"%.*s: %.*s",
14,
live_shorten_hex(ev->pubkey, pub_short, sizeof(pub_short)),
(w > 17) ? (w - 17) : 0,
preview);
}
}
wnoutrefresh(body);
tuin_render_footer(&status);
doupdate();
k = wgetch(body);
if (k == ERR) {
continue;
}
if (k == KEY_RESIZE) {
continue;
}
if (tuin_is_escape_key(k)) {
break;
}
if (input[0] == 'f' || input[0] == 'F') {
getmaxyx(body, h, w);
if ((k == KEY_DOWN || k == 'j') && top + 1 < buffer.count) {
top++;
} else if ((k == KEY_UP || k == 'k') && top > 0) {
top--;
} else if (k == KEY_NPAGE) {
top += (h > 3) ? (h - 3) : 1;
if (top >= buffer.count) {
top = (buffer.count > 0) ? (buffer.count - 1) : 0;
}
} else if (k == KEY_PPAGE) {
top -= (h > 3) ? (h - 3) : 1;
if (top < 0) {
top = 0;
}
} else if (k == KEY_HOME) {
top = 0;
} else if (k == KEY_END) {
top = (buffer.count > (h - 3)) ? (buffer.count - (h - 3)) : 0;
}
}
wtimeout(body, -1);
live_free_buffer(&buffer);
}
void menu_live(void) {
static const TuiMenuItem LIVE_ITEMS[] = {
{NT_HK("F", "ollows feed"), 'f'},
{NT_HK("M", "entions"), 'm'},
{NT_HK("G", "lobal firehose"), 'g'},
{NT_HK("X", "Exit"), 'x'},
};
TuiMenuState menu_state = {0};
while (1) {
TuiFrame frame = nt_frame("> Main Menu > Live Feeds");
TuiStatus status = nt_status();
TuiMenu menu = {LIVE_ITEMS, (int)(sizeof(LIVE_ITEMS) / sizeof(LIVE_ITEMS[0]))};
int idx = tuin_menu_run(&frame, &menu, &status, &menu_state);
if (idx < 0 || idx == 3) {
break;
}
if (idx == 0) {
char **authors = NULL;
int authors_count = 0;
cJSON *filter_arr = NULL;
@@ -284,9 +452,7 @@ void menu_live(void) {
int i;
if (live_extract_follow_authors(&authors, &authors_count) != 0 || authors_count <= 0) {
tui_begin_frame(&live_menu_view);
tui_print("No follows found in kind3 list.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
tuin_notice("No follows found in kind3 list.");
live_free_authors(authors, authors_count);
continue;
}
@@ -301,9 +467,7 @@ void menu_live(void) {
cJSON_Delete(authors_json);
cJSON_Delete(kinds_json);
live_free_authors(authors, authors_count);
tui_begin_frame(&live_menu_view);
tui_print("Failed to build follows filter.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
tuin_notice("Failed to build follows filter.");
continue;
}
@@ -317,23 +481,29 @@ void menu_live(void) {
filter_str = cJSON_PrintUnformatted(filter_arr);
if (filter_str) {
nt_log("Connected to follows live feed.");
menu_live_run_filter("FOLLOWS FEED", filter_str);
nt_log("Disconnected from follows live feed.");
free(filter_str);
}
cJSON_Delete(filter_arr);
live_free_authors(authors, authors_count);
} else if (input[0] == 'm' || input[0] == 'M') {
} else if (idx == 1) {
char filter[512];
snprintf(filter,
sizeof(filter),
"[{\"kinds\":[1],\"#p\":[\"%s\"],\"limit\":500}]",
g_state.npub_hex);
nt_log("Connected to mentions live feed.");
menu_live_run_filter("MENTIONS", filter);
} else if (input[0] == 'g' || input[0] == 'G') {
nt_log("Disconnected from mentions live feed.");
} else if (idx == 2) {
char filter[128];
snprintf(filter, sizeof(filter), "[{\"kinds\":[1],\"limit\":500}]");
nt_log("Connected to global live feed.");
menu_live_run_filter("FIREHOSE", filter);
nt_log("Disconnected from global live feed.");
}
}
}

View File

@@ -13,6 +13,7 @@
#include "../resources/nostr_core_lib/nostr_core/utils.h"
#include <ctype.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -21,6 +22,44 @@
#define NT_DEV_SEED_PHRASE "cube dirt movie learn depth axis ball view aunt electric finish release"
static void menu_render_body(const char *breadcrumb) {
TuiFrame frame = nt_frame(breadcrumb);
TuiStatus status = nt_status();
tuin_render_header(&frame);
nt_print_reset();
tuin_render_footer(&status);
}
static int menu_prompt(const char *breadcrumb,
const char *prompt,
const char *default_value,
char *out,
size_t out_size) {
menu_render_body(breadcrumb);
if (tuin_prompt(prompt, default_value ? default_value : "", out, out_size) != 0) {
if (out && out_size > 0) {
out[0] = '\0';
}
return -1;
}
return 0;
}
static void menu_noticef(const char *fmt, ...) {
char msg[1024];
va_list ap;
if (!fmt) {
return;
}
va_start(ap, fmt);
vsnprintf(msg, sizeof(msg), fmt, ap);
va_end(ap);
tuin_notice(msg);
}
static int menu_load_test_mnemonic(char *out, int out_size) {
FILE *fp;
@@ -94,10 +133,6 @@ static int menu_publish_signed_event(int kind, const char *content, cJSON *tags)
}
static void menu_add_new_user(void) {
static const tui_view_t login_new_profile_view = {
"> Login > New Account > Profile Setup",
NULL,
};
char buf[512];
char username[128];
cJSON *meta = cJSON_CreateObject();
@@ -107,35 +142,32 @@ static void menu_add_new_user(void) {
username[0] = '\0';
do {
tui_begin_frame(&login_new_profile_view);
(void)tui_end_frame_with_prompt("Enter a username for this account (required) >", username, (int)sizeof(username));
(void)menu_prompt("> Login > New Account > Profile Setup",
"Enter a username for this account (required)",
"",
username,
sizeof(username));
} while (username[0] == '\0');
cJSON_AddStringToObject(meta, "name", username);
cJSON_AddStringToObject(meta, "displayname", username);
tui_begin_frame(&login_new_profile_view);
(void)tui_end_frame_with_prompt("Enter \"about\" info: >", buf, (int)sizeof(buf));
(void)menu_prompt("> Login > New Account > Profile Setup", "Enter \"about\" info:", "", buf, sizeof(buf));
cJSON_AddStringToObject(meta, "about", buf);
tui_begin_frame(&login_new_profile_view);
(void)tui_end_frame_with_prompt("Enter profile picture url: >", buf, (int)sizeof(buf));
(void)menu_prompt("> Login > New Account > Profile Setup", "Enter profile picture url:", "", buf, sizeof(buf));
cJSON_AddStringToObject(meta, "picture", buf);
tui_begin_frame(&login_new_profile_view);
(void)tui_end_frame_with_prompt("Enter banner picture url: >", buf, (int)sizeof(buf));
(void)menu_prompt("> Login > New Account > Profile Setup", "Enter banner picture url:", "", buf, sizeof(buf));
cJSON_AddStringToObject(meta, "banner", buf);
tui_begin_frame(&login_new_profile_view);
(void)tui_end_frame_with_prompt("Enter website url: >", buf, (int)sizeof(buf));
(void)menu_prompt("> Login > New Account > Profile Setup", "Enter website url:", "", buf, sizeof(buf));
cJSON_AddStringToObject(meta, "website", buf);
tui_begin_frame(&login_new_profile_view);
(void)tui_end_frame_with_prompt("Enter lud16: >", buf, (int)sizeof(buf));
(void)menu_prompt("> Login > New Account > Profile Setup", "Enter lud16:", "", buf, sizeof(buf));
cJSON_AddStringToObject(meta, "lud16", buf);
tui_begin_frame(&login_new_profile_view);
(void)tui_end_frame_with_prompt("Enter nip05: >", buf, (int)sizeof(buf));
(void)menu_prompt("> Login > New Account > Profile Setup", "Enter nip05:", "", buf, sizeof(buf));
cJSON_AddStringToObject(meta, "nip05", buf);
free(g_state.kind0_json);
@@ -164,34 +196,21 @@ static void menu_add_new_user(void) {
}
void menu_login(void) {
static const tui_view_t login_view = {
"> Login",
NULL,
static const TuiMenuItem LOGIN_ITEMS[] = {
{NT_HK("P", "rivate key"), 'p'},
{NT_HK("M", "nemonic"), 'm'},
{"N_" NT_HK("s", "igner"), 's'},
{NT_HK("N", "ew account"), 'n'},
{NT_HK("Q", "uit"), 'q'},
};
static const tui_view_t login_privkey_view = {
"> Login > Private Key",
NULL,
};
static const tui_view_t login_mnemonic_view = {
"> Login > Mnemonic",
NULL,
};
static const tui_view_t login_signer_local_view = {
"> Login > Signer Local",
NULL,
};
static const tui_view_t login_signer_url_view = {
"> Login > URL Signer",
NULL,
};
static const tui_view_t login_signer_qrexec_view = {
"> Login > Qrexec Signer",
NULL,
};
static const tui_view_t login_new_account_view = {
"> Login > New Account",
NULL,
static const TuiMenuItem SIGNER_TRANSPORT_ITEMS[] = {
{"URL signer (FIPS/web address)", 'u'},
{"Signer qrexec transport", 's'},
{"Launch n_signer (stub)", 'l'},
{"Back", 'b'},
};
TuiMenuState login_state = {0};
char input[512];
char pending_test_mnemonic[512] = {0};
int pending_test_mnemonic_ready = 0;
@@ -199,134 +218,191 @@ void menu_login(void) {
while (1) {
unsigned char priv[32];
unsigned char pub[32];
int login_idx;
TuiFrame frame = nt_frame("> Login");
TuiMenu menu = {LOGIN_ITEMS, 5};
TuiStatus status = nt_status();
(void)tui_consume_resize_flag();
tui_begin_frame(&login_view);
tui_print_menu_item("^_E^:nter test/dev seed fallback");
tui_print_menu_item("^_P^:rivate key (nsec or 64-hex)");
tui_print_menu_item("^_M^:nemonic (12 words)");
tui_print_menu_item("^_S^:igner local (same qube)");
tui_print_menu_item("^_U^:RL signer (FIPS/web address)");
tui_print_menu_item("^_N^:ew account");
tui_print_menu_item("^_Q^:uit");
login_idx = tuin_menu_run(&frame, &menu, &status, &login_state);
(void)tui_end_frame_with_prompt(">", input, (int)sizeof(input));
if (strcmp(input, "q") == 0 || strcmp(input, "x") == 0) {
tui_print("\nHasta luego.\n");
if (login_idx < 0 || login_idx == 4) {
nt_log("Hasta luego.");
signer_shutdown();
exit(0);
}
if (input[0] == 'e' || input[0] == 'E') {
input[0] = '\0';
} else if (input[0] == 'p' || input[0] == 'P') {
tui_begin_frame(&login_privkey_view);
(void)tui_end_frame_with_prompt("Private key (nsec or 64-hex) >", input, (int)sizeof(input));
input[0] = '\0';
if (login_idx == 0) {
(void)menu_prompt("> Login > Private Key", "Private key (nsec or 64-hex)", "", input, sizeof(input));
if (input[0] == '\0') {
continue;
}
} else if (input[0] == 'm' || input[0] == 'M') {
tui_begin_frame(&login_mnemonic_view);
(void)tui_end_frame_with_prompt("Seed phrase (12 words) >", input, (int)sizeof(input));
} else if (login_idx == 1) {
(void)menu_prompt("> Login > Mnemonic", "Seed phrase (12 words)", "", input, sizeof(input));
if (input[0] == '\0') {
continue;
}
} else if (login_idx == 2) {
snprintf(input, sizeof(input), "%s", "s");
} else if (login_idx == 3) {
snprintf(input, sizeof(input), "%s", "n");
}
if (strcmp(input, "s") == 0 || strcmp(input, "S") == 0) {
char **names = NULL;
int name_count = 0;
char pubkey_hex[65] = {0};
int selected = 0;
nt_nsigner_selector_t selector = nsigner_selector_default();
char idxbuf[32];
int nostr_index = 0;
tui_begin_frame(&login_signer_local_view);
tui_print("Searching for running n_signer instances...");
if (nsigner_list(&names, &name_count) != 0 || name_count == 0) {
tui_print("No running n_signer found. Start `nsigner` and try again.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
continue;
}
if (name_count == 1) {
tui_print("Found: @%s", names[0]);
selected = 0;
} else {
if (nsigner_list(&names, &name_count) == 0 && name_count > 0) {
char pubkey_hex[65] = {0};
int selected = 0;
nt_nsigner_selector_t selector = nsigner_selector_default();
char idxbuf[32] = {0};
int nostr_index = 0;
int choose_transport = 0;
int idx;
char pickbuf[16];
tui_print("Found %d n_signer instances:", name_count);
int local_choice;
TuiMenuState local_state = {0};
TuiMenuItem *local_items = NULL;
char (*labels)[256] = NULL;
local_items = (TuiMenuItem *)calloc((size_t)(name_count + 2), sizeof(TuiMenuItem));
labels = (char(*)[256])calloc((size_t)(name_count + 2), sizeof(*labels));
if (!local_items || !labels) {
for (idx = 0; idx < name_count; idx++) {
free(names[idx]);
}
free(names);
free(local_items);
free(labels);
menu_noticef("Out of memory while building local n_signer menu.");
continue;
}
for (idx = 0; idx < name_count; idx++) {
tui_print(" [%d] @%s", idx, names[idx]);
snprintf(labels[idx], sizeof(labels[idx]), "@%s", names[idx]);
local_items[idx].label = labels[idx];
local_items[idx].shortcut = 0;
}
(void)tui_end_frame_with_prompt("Select (0) >", pickbuf, (int)sizeof(pickbuf));
selected = (pickbuf[0] != '\0') ? atoi(pickbuf) : 0;
if (selected < 0 || selected >= name_count) {
selected = 0;
}
}
snprintf(labels[name_count], sizeof(labels[name_count]), "Transport options");
local_items[name_count].label = labels[name_count];
local_items[name_count].shortcut = 't';
tui_begin_frame(&login_signer_local_view);
(void)tui_end_frame_with_prompt("Seed phrase index (0) >", idxbuf, (int)sizeof(idxbuf));
if (idxbuf[0] != '\0') {
nostr_index = atoi(idxbuf);
if (nostr_index < 0) {
nostr_index = 0;
}
}
selector.has_nostr_index = 1;
selector.nostr_index = nostr_index;
selector.role[0] = '\0';
snprintf(labels[name_count + 1], sizeof(labels[name_count + 1]), "Back");
local_items[name_count + 1].label = labels[name_count + 1];
local_items[name_count + 1].shortcut = 'b';
tui_begin_frame(&login_signer_local_view);
tui_print("Waiting for n_signer approval...");
if (nsigner_get_public_key(names[selected], &selector, pubkey_hex) != 0) {
int i;
tui_print("Failed to get public key from n_signer (denied or error).");
for (i = 0; i < name_count; i++) {
free(names[i]);
{
TuiFrame local_frame = nt_frame("> Login > Signer Local");
TuiMenu local_menu = {local_items, name_count + 2};
TuiStatus local_status = nt_status();
local_choice = tuin_menu_run(&local_frame, &local_menu, &local_status, &local_state);
}
if (local_choice < 0 || local_choice == name_count + 1) {
for (idx = 0; idx < name_count; idx++) {
free(names[idx]);
}
free(names);
free(local_items);
free(labels);
continue;
}
if (local_choice == name_count) {
choose_transport = 1;
} else {
selected = local_choice;
(void)menu_prompt("> Login > Signer Local", "Seed phrase index (0)", "", idxbuf, sizeof(idxbuf));
}
if (idxbuf[0] != '\0') {
nostr_index = atoi(idxbuf);
if (nostr_index < 0) {
nostr_index = 0;
}
}
if (!choose_transport) {
selector.has_nostr_index = 1;
selector.nostr_index = nostr_index;
selector.role[0] = '\0';
menu_render_body("> Login > Signer Local");
nt_log("Waiting for n_signer approval...");
if (nsigner_get_public_key(names[selected], &selector, pubkey_hex) != 0) {
for (idx = 0; idx < name_count; idx++) {
free(names[idx]);
}
free(names);
free(local_items);
free(labels);
tuin_notice("Failed to get public key from n_signer (denied or error).");
continue;
}
signer_init_nsigner(names[selected], &selector);
snprintf(g_state.npub_hex, sizeof(g_state.npub_hex), "%s", pubkey_hex);
{
unsigned char pub_bytes[32];
nostr_hex_to_bytes(pubkey_hex, pub_bytes, 32);
nostr_key_to_bech32(pub_bytes, "npub", g_state.npub_bech32);
}
g_state.nsec_hex[0] = '\0';
g_state.nsec_bech32[0] = '\0';
g_state.seed_phrase[0] = '\0';
g_state.logged_in = 1;
nt_log("Signed in via n_signer (@%s) npub: %s", names[selected], g_state.npub_bech32);
for (idx = 0; idx < name_count; idx++) {
free(names[idx]);
}
free(names);
free(local_items);
free(labels);
(void)state_load_user_info();
if (g_state.kind0_json && strcmp(g_state.kind0_json, "{}") == 0) {
nt_log("No profile found on relays. Setting up new user...");
menu_add_new_user();
(void)state_load_user_info();
}
return;
}
for (idx = 0; idx < name_count; idx++) {
free(names[idx]);
}
free(names);
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
continue;
free(local_items);
free(labels);
}
signer_init_nsigner(names[selected], &selector);
snprintf(g_state.npub_hex, sizeof(g_state.npub_hex), "%s", pubkey_hex);
{
unsigned char pub_bytes[32];
nostr_hex_to_bytes(pubkey_hex, pub_bytes, 32);
nostr_key_to_bech32(pub_bytes, "npub", g_state.npub_bech32);
}
g_state.nsec_hex[0] = '\0';
g_state.nsec_bech32[0] = '\0';
g_state.seed_phrase[0] = '\0';
g_state.logged_in = 1;
int transport_idx;
TuiMenuState transport_state = {0};
TuiFrame transport_frame = nt_frame("> Login > N_signer > Transport");
TuiMenu transport_menu = {SIGNER_TRANSPORT_ITEMS, 4};
TuiStatus transport_status = nt_status();
tui_print("Signed in via n_signer (@%s)", names[selected]);
tui_print("npub: %s", g_state.npub_bech32);
transport_idx = tuin_menu_run(&transport_frame, &transport_menu, &transport_status, &transport_state);
{
int i;
for (i = 0; i < name_count; i++) {
free(names[i]);
if (transport_idx == 0) {
snprintf(input, sizeof(input), "%s", "u");
} else if (transport_idx == 1) {
snprintf(input, sizeof(input), "%s", "S");
} else if (transport_idx == 2) {
tuin_notice("Launch n_signer is not implemented yet.");
continue;
} else {
continue;
}
free(names);
}
(void)state_load_user_info();
if (g_state.kind0_json && strcmp(g_state.kind0_json, "{}") == 0) {
tui_print("No profile found on relays. Setting up new user...");
menu_add_new_user();
(void)state_load_user_info();
}
return;
}
if (strcmp(input, "u") == 0 || strcmp(input, "U") == 0) {
@@ -340,21 +416,22 @@ void menu_login(void) {
nt_nsigner_auth_ctx_t auth = {0};
snprintf(endpoint_url, sizeof(endpoint_url), "%s", (saved_endpoint && saved_endpoint[0] != '\0') ? saved_endpoint : "");
tui_begin_frame(&login_signer_url_view);
(void)tui_end_frame_with_prompt("Signer URL (http://<npub>.fips:8080) >", endpoint_url, (int)sizeof(endpoint_url));
(void)menu_prompt("> Login > URL Signer",
"Signer URL (http://<npub>.fips:8080)",
endpoint_url,
endpoint_url,
sizeof(endpoint_url));
if (endpoint_url[0] == '\0') {
if (saved_endpoint && saved_endpoint[0] != '\0') {
snprintf(endpoint_url, sizeof(endpoint_url), "%s", saved_endpoint);
} else {
tui_begin_frame(&login_signer_url_view);
tui_print("No URL provided.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
tuin_notice("No URL provided.");
continue;
}
}
tui_begin_frame(&login_signer_url_view);
(void)tui_end_frame_with_prompt("Seed phrase index (0) >", idxbuf, (int)sizeof(idxbuf));
(void)menu_prompt("> Login > URL Signer", "Seed phrase index (0)", "", idxbuf, sizeof(idxbuf));
if (idxbuf[0] != '\0') {
nostr_index = atoi(idxbuf);
if (nostr_index < 0) {
@@ -365,36 +442,32 @@ void menu_login(void) {
selector.nostr_index = nostr_index;
selector.role[0] = '\0';
tui_begin_frame(&login_signer_url_view);
tui_print("Connecting to n_signer URL: %s", endpoint_url);
tui_print("Waiting for n_signer approval...");
menu_render_body("> Login > URL Signer");
nt_log("Connecting to n_signer URL: %s", endpoint_url);
nt_log("Waiting for n_signer approval...");
auth.enabled = 1;
if (getrandom(auth.privkey, sizeof(auth.privkey), 0) != (ssize_t)sizeof(auth.privkey)) {
tui_print("Failed to generate URL auth key.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
tuin_notice("Failed to generate URL auth key.");
continue;
}
snprintf(auth.label, sizeof(auth.label), "%s", "nostr_terminal");
session_fd = nsigner_session_open_url(endpoint_url);
if (session_fd < 0) {
tui_print("Failed to open persistent connection to n_signer URL.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
tuin_notice("Failed to open persistent connection to n_signer URL.");
continue;
}
if (nsigner_session_get_public_key(&session_fd, endpoint_url, &selector, &auth, pubkey_hex) != 0) {
nsigner_session_close(session_fd);
tui_print("Failed to get public key from n_signer URL (denied or error).");
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
tuin_notice("Failed to get public key from n_signer URL (denied or error).");
continue;
}
if (signer_init_nsigner_url_with_session_auth(endpoint_url, &selector, session_fd, &auth) != 0) {
nsigner_session_close(session_fd);
tui_print("Failed to initialize URL signer mode.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
tuin_notice("Failed to initialize URL signer mode.");
continue;
}
@@ -411,13 +484,12 @@ void menu_login(void) {
g_state.seed_phrase[0] = '\0';
g_state.logged_in = 1;
tui_print("Signed in via n_signer URL (%s)", endpoint_url);
tui_print("npub: %s", g_state.npub_bech32);
nt_log("Signed in via n_signer URL (%s) npub: %s", endpoint_url, g_state.npub_bech32);
(void)state_load_user_info();
if (g_state.kind0_json && strcmp(g_state.kind0_json, "{}") == 0) {
tui_print("No profile found on relays. Setting up new user...");
nt_log("No profile found on relays. Setting up new user...");
menu_add_new_user();
(void)state_load_user_info();
}
@@ -437,20 +509,25 @@ void menu_login(void) {
snprintf(target_qube, sizeof(target_qube), "%s", (saved_target && saved_target[0] != '\0') ? saved_target : "nsigner-vault");
snprintf(service_name, sizeof(service_name), "%s", "qubes.NsignerRpc");
tui_begin_frame(&login_signer_qrexec_view);
(void)tui_end_frame_with_prompt("Target signer qube (nsigner-vault) >", target_qube, (int)sizeof(target_qube));
(void)menu_prompt("> Login > Qrexec Signer",
"Target signer qube (nsigner-vault)",
target_qube,
target_qube,
sizeof(target_qube));
if (target_qube[0] == '\0') {
snprintf(target_qube, sizeof(target_qube), "%s", (saved_target && saved_target[0] != '\0') ? saved_target : "nsigner-vault");
}
tui_begin_frame(&login_signer_qrexec_view);
(void)tui_end_frame_with_prompt("Qrexec service (qubes.NsignerRpc) >", service_name, (int)sizeof(service_name));
(void)menu_prompt("> Login > Qrexec Signer",
"Qrexec service (qubes.NsignerRpc)",
service_name,
service_name,
sizeof(service_name));
if (service_name[0] == '\0') {
snprintf(service_name, sizeof(service_name), "%s", "qubes.NsignerRpc");
}
tui_begin_frame(&login_signer_qrexec_view);
(void)tui_end_frame_with_prompt("Seed phrase index (0) >", idxbuf, (int)sizeof(idxbuf));
(void)menu_prompt("> Login > Qrexec Signer", "Seed phrase index (0)", "", idxbuf, sizeof(idxbuf));
if (idxbuf[0] != '\0') {
nostr_index = atoi(idxbuf);
if (nostr_index < 0) {
@@ -461,19 +538,17 @@ void menu_login(void) {
selector.nostr_index = nostr_index;
selector.role[0] = '\0';
tui_begin_frame(&login_signer_qrexec_view);
tui_print("Calling qrexec service %s in %s...", service_name, target_qube);
tui_print("Waiting for n_signer approval...");
menu_render_body("> Login > Qrexec Signer");
nt_log("Calling qrexec service %s in %s...", service_name, target_qube);
nt_log("Waiting for n_signer approval...");
if (nsigner_qrexec_get_public_key(target_qube, service_name, &selector, pubkey_hex) != 0) {
tui_print("Failed to get public key via qrexec (denied or error).");
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
tuin_notice("Failed to get public key via qrexec (denied or error).");
continue;
}
if (signer_init_nsigner_qrexec(target_qube, service_name, &selector) != 0) {
tui_print("Failed to initialize qrexec signer mode.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
tuin_notice("Failed to initialize qrexec signer mode.");
continue;
}
@@ -490,13 +565,12 @@ void menu_login(void) {
g_state.seed_phrase[0] = '\0';
g_state.logged_in = 1;
tui_print("Signed in via qrexec n_signer (%s:%s)", target_qube, service_name);
tui_print("npub: %s", g_state.npub_bech32);
nt_log("Signed in via qrexec n_signer (%s:%s) npub: %s", target_qube, service_name, g_state.npub_bech32);
(void)state_load_user_info();
if (g_state.kind0_json && strcmp(g_state.kind0_json, "{}") == 0) {
tui_print("No profile found on relays. Setting up new user...");
nt_log("No profile found on relays. Setting up new user...");
menu_add_new_user();
(void)state_load_user_info();
}
@@ -510,44 +584,42 @@ void menu_login(void) {
int account = 0;
if (nostr_generate_mnemonic_and_keys(mnemonic, sizeof(mnemonic), 0, priv, pub) != 0) {
tui_begin_frame(&login_new_account_view);
tui_print("Failed to generate new seed phrase.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
tuin_notice("Failed to generate new seed phrase.");
continue;
}
tui_begin_frame(&login_new_account_view);
(void)tui_end_frame_with_prompt("Enter seed phrase index (0):", idxbuf, (int)sizeof(idxbuf));
(void)menu_prompt("> Login > New Account", "Enter seed phrase index (0):", "", idxbuf, sizeof(idxbuf));
if (idxbuf[0] != '\0') {
account = atoi(idxbuf);
if (account < 0) {
account = 0;
}
if (nostr_derive_keys_from_mnemonic(mnemonic, account, priv, pub) != 0) {
tui_begin_frame(&login_new_account_view);
tui_print("Failed to derive keys from generated mnemonic.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
tuin_notice("Failed to derive keys from generated mnemonic.");
continue;
}
}
if (menu_set_keys_from_private_bytes(priv, mnemonic) != 0) {
tui_begin_frame(&login_new_account_view);
tui_print("Could not initialize keys.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
tuin_notice("Could not initialize keys.");
continue;
}
tui_print("");
tui_print("Jot this down!");
tui_print("");
tui_print("Seed phrase: %s", g_state.seed_phrase);
tui_print("");
tui_print("nsecHex: %s", g_state.nsec_hex);
tui_print("nsec: %s", g_state.nsec_bech32);
tui_print("npubHex: %s", g_state.npub_hex);
tui_print("npub: %s", g_state.npub_bech32);
tui_print("");
menu_render_body("> Login > New Account");
nt_log("");
nt_log("Jot this down!");
nt_log("");
nt_log("Seed phrase: %s", g_state.seed_phrase);
nt_log("");
nt_log("nsecHex: %s", g_state.nsec_hex);
nt_log("nsec: %s", g_state.nsec_bech32);
nt_log("npubHex: %s", g_state.npub_hex);
nt_log("npub: %s", g_state.npub_bech32);
nt_log("");
if (tuin_confirm("Continue with account setup?") != 1) {
continue;
}
menu_add_new_user();
(void)state_load_user_info();
@@ -560,7 +632,7 @@ void menu_login(void) {
pending_test_mnemonic_ready = 0;
} else if (menu_load_test_mnemonic(pending_test_mnemonic, (int)sizeof(pending_test_mnemonic)) == 0) {
pending_test_mnemonic_ready = 1;
tui_print("Loaded .test_mnemonic. Press Enter again to use it.");
nt_log("Loaded .test_mnemonic. Trigger mnemonic/private flow to use it.");
continue;
} else {
snprintf(input, sizeof(input), "%s", NT_DEV_SEED_PHRASE);
@@ -584,9 +656,7 @@ void menu_login(void) {
(void)state_load_user_info();
return;
}
tui_begin_frame(&login_privkey_view);
tui_print("Invalid nsecHex. Try again.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
tuin_notice("Invalid nsecHex. Try again.");
continue;
}
}
@@ -597,9 +667,7 @@ void menu_login(void) {
(void)state_load_user_info();
return;
}
tui_begin_frame(&login_privkey_view);
tui_print("Invalid nsec. Try again.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
tuin_notice("Invalid nsec. Try again.");
continue;
}
@@ -620,14 +688,11 @@ void menu_login(void) {
int account = 0;
if (nostr_bip39_mnemonic_validate(input) != 0) {
tui_begin_frame(&login_mnemonic_view);
tui_print("Invalid seed phrase. Try again.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
tuin_notice("Invalid seed phrase. Try again.");
continue;
}
tui_begin_frame(&login_mnemonic_view);
(void)tui_end_frame_with_prompt("Enter seed phrase index (0) >", idxbuf, (int)sizeof(idxbuf));
(void)menu_prompt("> Login > Mnemonic", "Enter seed phrase index (0)", "", idxbuf, sizeof(idxbuf));
if (idxbuf[0] != '\0') {
account = atoi(idxbuf);
if (account < 0) {
@@ -637,23 +702,17 @@ void menu_login(void) {
if (nostr_derive_keys_from_mnemonic(input, account, priv, pub) != 0 ||
menu_set_keys_from_private_bytes(priv, input) != 0) {
tui_begin_frame(&login_mnemonic_view);
tui_print("Failed to derive keys from seed phrase.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
tuin_notice("Failed to derive keys from seed phrase.");
continue;
}
tui_begin_frame(&login_mnemonic_view);
tui_print("Using seed phrase: %s", g_state.seed_phrase);
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
nt_log("Using seed phrase: %s", g_state.seed_phrase);
(void)state_load_user_info();
return;
}
}
tui_begin_frame(&login_view);
tui_print("Invalid login input. Choose a menu command or enter valid key/mnemonic data.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
tuin_notice("Invalid login input. Choose a menu command or enter valid key/mnemonic data.");
}
}

View File

@@ -17,6 +17,11 @@ typedef struct {
char *content;
} notif_item_t;
typedef struct {
const notif_item_t *items;
int count;
} notif_table_ctx_t;
static char *notif_strdup(const char *s) {
size_t n;
char *out;
@@ -168,11 +173,41 @@ static int notif_cmp_desc_created(const void *a, const void *b) {
return 0;
}
static void notif_table_get_cell(int row, int col, char *out, size_t out_size, void *user_data) {
const notif_table_ctx_t *ctx = (const notif_table_ctx_t *)user_data;
const notif_item_t *it;
char pub_short[32];
if (!out || out_size == 0U) {
return;
}
out[0] = '\0';
if (!ctx || row < 0 || row >= ctx->count) {
return;
}
it = &ctx->items[row];
if (col == 0) {
snprintf(out, out_size, "%s", (it->kind == 7) ? "♥ reaction" : "↩ reply");
} else if (col == 1) {
snprintf(out, out_size, "%s", notif_shorten_hex(it->pubkey, pub_short, sizeof(pub_short)));
} else if (col == 2) {
if (it->kind == 7) {
char ev_short[32];
snprintf(out,
out_size,
"%s",
notif_shorten_hex(it->event_ref ? it->event_ref : "(unknown)", ev_short, sizeof(ev_short)));
} else {
char preview[160];
snprintf(out, out_size, "%s", notif_preview_text(it->content, preview, sizeof(preview)));
}
}
}
void menu_notifications(void) {
static const tui_view_t notifications_view = {
"> Main Menu > Notifications",
NULL,
};
const char **read_relays;
int relay_count = 0;
time_t now;
@@ -185,7 +220,16 @@ void menu_notifications(void) {
int reactions = 0;
int replies = 0;
int i;
char input[32];
char status_text[128];
TuiFrame frame = nt_frame("> Main Menu > Notifications");
TuiColumn cols[] = {
{"Type", 12, 0},
{"From", 20, 0},
{"Detail", 0, 0},
};
notif_table_ctx_t ctx;
TuiTable table;
TuiStatus status;
read_relays = state_get_read_relays(&relay_count);
@@ -198,12 +242,9 @@ void menu_notifications(void) {
g_state.npub_hex,
since);
tui_begin_frame(&notifications_view);
tui_print("Querying %d read relay(s)...", relay_count);
nt_log("Fetching notifications...");
if (nt_query_sync(filter, read_relays, relay_count, 7000, &events, &event_count) != 0) {
tui_print("Failed to query notifications (all relays failed).");
(void)tui_end_frame_with_prompt("Press Enter to exit >", input, (int)sizeof(input));
tuin_notice("Failed to query notifications (all relays failed).");
return;
}
@@ -274,38 +315,33 @@ void menu_notifications(void) {
qsort(items, (size_t)items_count, sizeof(notif_item_t), notif_cmp_desc_created);
}
tui_begin_frame(&notifications_view);
tui_print("%d reactions, %d replies in last 3 days", reactions, replies);
tui_print("");
for (i = 0; i < items_count; i++) {
char pub_short[32];
if (items[i].kind == 7) {
char ev_short[32];
tui_print("♥ %s reacted to %s",
notif_shorten_hex(items[i].pubkey, pub_short, sizeof(pub_short)),
notif_shorten_hex(items[i].event_ref ? items[i].event_ref : "(unknown)",
ev_short,
sizeof(ev_short)));
} else if (items[i].kind == 1) {
char preview[160];
tui_print("↩ %s replied: %s",
notif_shorten_hex(items[i].pubkey, pub_short, sizeof(pub_short)),
notif_preview_text(items[i].content, preview, sizeof(preview)));
}
}
if (items_count == 0) {
tui_print("No notifications found.");
}
for (i = 0; i < event_count; i++) {
free(events[i]);
}
free(events);
notif_free_items(items, items_count);
if (items_count == 0) {
nt_log("No notifications found.");
notif_free_items(items, items_count);
return;
}
(void)tui_end_frame_with_prompt("Press Enter to exit >", input, (int)sizeof(input));
snprintf(status_text, sizeof(status_text), "%d reactions, %d replies in last 3 days", reactions, replies);
ctx.items = items;
ctx.count = items_count;
table.columns = cols;
table.column_count = (int)(sizeof(cols) / sizeof(cols[0]));
table.row_count = items_count;
table.user_data = &ctx;
table.get_cell = notif_table_get_cell;
table.is_default = NULL;
table.prefix_len = NULL;
status.text = status_text;
(void)tuin_table_run(&frame, &table, &status);
notif_free_items(items, items_count);
}

View File

@@ -9,6 +9,7 @@
#include "../resources/nostr_core_lib/nostr_core/nip004.h"
#include "../resources/nostr_core_lib/nostr_core/utils.h"
#include <ncurses.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -26,6 +27,35 @@ typedef struct {
char *dtag;
} post_item_t;
typedef struct {
const post_item_t *items;
int count;
} posts_table_ctx_t;
typedef struct {
const char *initial;
char *edited;
} posts_editor_ctx_t;
static int posts_editor_spawn(void *user) {
posts_editor_ctx_t *ctx = (posts_editor_ctx_t *)user;
if (!ctx) {
return -1;
}
ctx->edited = editor_launch(ctx->initial);
return 0;
}
static char *posts_launch_editor(const char *initial) {
posts_editor_ctx_t ctx;
ctx.initial = initial;
ctx.edited = NULL;
if (nt_run_external_editor(posts_editor_spawn, &ctx) != 0) {
return NULL;
}
return ctx.edited;
}
static char *posts_strdup(const char *s) {
size_t n;
char *out;
@@ -162,26 +192,6 @@ static void posts_format_date(long long created_at, char *buf, size_t buf_sz) {
}
}
static void posts_print_list_item_responsive(int index,
int kind,
const char *kind_label,
const char *label,
const char *date_buf,
int term_width) {
if (term_width < 80) {
tui_print("[%2d] %s", index, label ? label : "(untitled)");
tui_print(" %s %s", kind_label ? kind_label : "?", date_buf ? date_buf : "");
return;
}
tui_print("[%2d] (%d/%s) %s %s",
index,
kind,
kind_label ? kind_label : "?",
label ? label : "(untitled)",
date_buf ? date_buf : "");
}
static int posts_build_list(const char *filter_json, post_item_t **items_out, int *count_out) {
const char **read_relays;
int relay_count = 0;
@@ -298,69 +308,175 @@ static const char *posts_kind_label(int kind) {
return "post";
}
static void posts_view_item(const post_item_t *it) {
static const tui_view_t post_view = {
"> Main Menu > Posts > View",
NULL,
};
static int posts_build_detail_lines(const post_item_t *it, char ***lines_out, int *count_out) {
char **lines = NULL;
int count = 0;
char date_buf[64];
char input[16];
char *decrypted = NULL;
char *render;
const char *content;
size_t i;
size_t start = 0;
if (!it) {
return;
if (!it || !lines_out || !count_out) {
return -1;
}
*lines_out = NULL;
*count_out = 0;
posts_format_date(it->created_at, date_buf, sizeof(date_buf));
if (it->kind == 30024 || it->kind == 30078) {
decrypted = posts_try_decrypt_nip04(it->pubkey, it->content);
}
tui_begin_frame(&post_view);
tui_print("id: %s", it->id);
tui_print("kind: %d (%s)", it->kind, posts_kind_label(it->kind));
tui_print("date: %s", date_buf);
tui_print("d: %s", (it->dtag && it->dtag[0] != '\0') ? it->dtag : "");
tui_print("title: %s", (it->title && it->title[0] != '\0') ? it->title : "");
tui_print("");
if (decrypted) {
tui_print("%s", decrypted);
content = decrypted;
} else if (it->kind == 30024 || it->kind == 30078) {
tui_print("(decrypt failed; showing raw content)");
tui_print("%s", it->content);
content = "(decrypt failed; showing raw content)\n";
} else {
tui_print("%s", it->content);
content = it->content;
}
#define POSTS_PUSH_LINE(txt) \
do { \
char **next = (char **)realloc(lines, (size_t)(count + 1) * sizeof(char *)); \
if (!next) { \
goto fail; \
} \
lines = next; \
lines[count] = posts_strdup((txt) ? (txt) : ""); \
if (!lines[count]) { \
goto fail; \
} \
count++; \
} while (0)
{
char hdr[1024];
snprintf(hdr, sizeof(hdr), "id: %s", it->id ? it->id : "");
POSTS_PUSH_LINE(hdr);
}
{
char hdr[256];
snprintf(hdr, sizeof(hdr), "kind: %d (%s)", it->kind, posts_kind_label(it->kind));
POSTS_PUSH_LINE(hdr);
}
{
char hdr[256];
snprintf(hdr, sizeof(hdr), "date: %s", date_buf);
POSTS_PUSH_LINE(hdr);
}
{
char hdr[1024];
snprintf(hdr, sizeof(hdr), "d: %s", (it->dtag && it->dtag[0] != '\0') ? it->dtag : "");
POSTS_PUSH_LINE(hdr);
}
{
char hdr[1024];
snprintf(hdr, sizeof(hdr), "title: %s", (it->title && it->title[0] != '\0') ? it->title : "");
POSTS_PUSH_LINE(hdr);
}
POSTS_PUSH_LINE("");
if (!decrypted && (it->kind == 30024 || it->kind == 30078)) {
POSTS_PUSH_LINE("(decrypt failed; showing raw content)");
render = it->content ? it->content : "";
} else {
render = (char *)content;
}
for (i = 0; ; i++) {
if (render[i] == '\n' || render[i] == '\0') {
size_t len = i - start;
char *ln = (char *)malloc(len + 1U);
if (!ln) {
goto fail;
}
memcpy(ln, render + start, len);
ln[len] = '\0';
POSTS_PUSH_LINE(ln);
free(ln);
if (render[i] == '\0') {
break;
}
start = i + 1U;
}
}
free(decrypted);
(void)tui_end_frame_with_prompt("Press Enter to exit >", input, (int)sizeof(input));
*lines_out = lines;
*count_out = count;
return 0;
fail:
for (i = 0; i < (size_t)count; i++) {
free(lines[i]);
}
free(lines);
free(decrypted);
return -1;
}
static void posts_free_detail_lines(char **lines, int count) {
int i;
for (i = 0; i < count; i++) {
free(lines[i]);
}
free(lines);
}
static void posts_view_item(const post_item_t *it) {
char **lines = NULL;
int line_count = 0;
size_t total = 1U;
char *text;
int i;
if (!it) {
return;
}
if (posts_build_detail_lines(it, &lines, &line_count) != 0) {
tuin_notice("Failed to render post content.");
return;
}
for (i = 0; i < line_count; i++) {
total += strlen(lines[i] ? lines[i] : "") + 1U;
}
text = (char *)malloc(total);
if (!text) {
posts_free_detail_lines(lines, line_count);
tuin_notice("Out of memory while preparing post view.");
return;
}
text[0] = '\0';
for (i = 0; i < line_count; i++) {
strcat(text, lines[i] ? lines[i] : "");
strcat(text, "\n");
}
nt_pager_show_text("> Main Menu > Posts > View", text);
free(text);
posts_free_detail_lines(lines, line_count);
}
static void posts_delete_item(const post_item_t *it) {
static const tui_view_t post_delete = {
"> Main Menu > Posts > Delete",
NULL,
};
cJSON *tags;
cJSON *e_tag;
char input[16];
char yn[8];
int posted;
if (!it || !it->id) {
return;
}
tui_begin_frame(&post_delete);
tui_print("Delete this post from relays?");
tui_print("id: %.24s...", it->id);
(void)tui_end_frame_with_prompt("Confirm delete [y/n] >", yn, (int)sizeof(yn));
if (!(yn[0] == 'y' || yn[0] == 'Y')) {
tui_begin_frame(&post_delete);
tui_print("Delete canceled.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
if (tuin_confirm("Delete this post from relays?") != 1) {
nt_log("Delete canceled.");
return;
}
@@ -382,27 +498,19 @@ static void posts_delete_item(const post_item_t *it) {
posted = publish_signed_event_with_report("Delete", 5, "", tags, 8000);
cJSON_Delete(tags);
tui_begin_frame(&post_delete);
if (posted < 0) {
tui_print("Delete publish failed.");
tuin_notice("Delete publish failed.");
} else {
tui_print("Deletion published.");
nt_log("Deletion published.");
}
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
}
static void posts_edit_item(const post_item_t *it) {
static const tui_view_t post_edit = {
"> Main Menu > Posts > Edit",
NULL,
};
char *initial = NULL;
char *edited = NULL;
char *out_content = NULL;
cJSON *tags_copy = NULL;
int posted;
char input[16];
if (!it) {
return;
@@ -418,28 +526,22 @@ static void posts_edit_item(const post_item_t *it) {
}
if (!initial) {
tui_begin_frame(&post_edit);
tui_print("Failed to prepare editor content.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
tuin_notice("Failed to prepare editor content.");
return;
}
edited = editor_launch(initial);
edited = posts_launch_editor(initial);
free(initial);
if (!edited) {
tui_begin_frame(&post_edit);
tui_print("Edit canceled.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
nt_log("Edit canceled.");
return;
}
if (it->kind == 30024 || it->kind == 30078) {
if (posts_encrypt_nip04_for_self(edited, &out_content) != 0) {
tui_begin_frame(&post_edit);
tui_print("Failed to encrypt updated content.");
free(edited);
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
tuin_notice("Failed to encrypt updated content.");
return;
}
} else {
@@ -454,9 +556,7 @@ static void posts_edit_item(const post_item_t *it) {
if (!tags_copy) {
free(edited);
free(out_content);
tui_begin_frame(&post_edit);
tui_print("Failed to duplicate tags.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
tuin_notice("Failed to duplicate tags.");
return;
}
@@ -465,89 +565,120 @@ static void posts_edit_item(const post_item_t *it) {
free(edited);
free(out_content);
tui_begin_frame(&post_edit);
if (posted < 0) {
tui_print("Post update failed.");
tuin_notice("Post update failed.");
} else {
tui_print("Post updated.");
nt_log("Post updated.");
}
}
static void posts_table_get_cell(int row, int col, char *out, size_t out_size, void *user_data) {
const posts_table_ctx_t *ctx = (const posts_table_ctx_t *)user_data;
const post_item_t *it;
char date_buf[64];
const char *label;
if (!out || out_size == 0U) {
return;
}
out[0] = '\0';
if (!ctx || row < 0 || row >= ctx->count) {
return;
}
it = &ctx->items[row];
label = (it->title && it->title[0] != '\0')
? it->title
: ((it->dtag && it->dtag[0] != '\0') ? it->dtag : "(untitled)");
posts_format_date(it->created_at, date_buf, sizeof(date_buf));
if (col == 0) {
snprintf(out, out_size, "%d", row + 1);
} else if (col == 1) {
snprintf(out, out_size, "%d/%s", it->kind, posts_kind_label(it->kind));
} else if (col == 2) {
snprintf(out, out_size, "%s", label);
} else if (col == 3) {
snprintf(out, out_size, "%s", date_buf);
}
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
}
static void posts_kind_menu(const char *title, const char *filter_json) {
static const tui_view_t posts_kind_view = {
"> Main Menu > Posts > List",
NULL,
static const TuiMenuItem ACTION_ITEMS[] = {
{NT_HK("V", "iew selected"), 'v'},
{NT_HK("E", "dit selected"), 'e'},
{NT_HK("D", "elete selected"), 'd'},
{NT_HK("X", "Exit"), 'x'},
};
post_item_t *items = NULL;
int items_count = 0;
char input[32];
int selected = 0;
TuiMenuState action_state = {0};
nt_log("Fetching posts...");
if (posts_build_list(filter_json, &items, &items_count) != 0) {
tui_begin_frame(&posts_kind_view);
tui_print("%s", title ? title : "Posts");
tui_print("Failed to query posts (all relays failed).");
(void)tui_end_frame_with_prompt("Press Enter to exit >", input, (int)sizeof(input));
tuin_notice("Failed to query posts (all relays failed).");
return;
}
while (1) {
int i;
int term_width = 0;
TuiFrame frame = nt_frame("> Main Menu > Posts > List");
char status_text[256];
TuiStatus status;
TuiColumn cols[] = {
{"#", 4, 1},
{"Kind", 16, 0},
{"Title", 0, 0},
{"Date", 18, 0},
};
posts_table_ctx_t ctx;
TuiTable table;
TuiMenu menu = {ACTION_ITEMS, 4};
int row;
int action;
tui_begin_frame(&posts_kind_view);
tui_print("%s", title ? title : "Posts");
tui_get_terminal_size(&term_width, NULL);
for (i = 0; i < items_count; i++) {
char date_buf[64];
const char *label;
snprintf(status_text, sizeof(status_text), "%s", title ? title : "Posts");
status.text = status_text;
posts_format_date(items[i].created_at, date_buf, sizeof(date_buf));
label = (items[i].title && items[i].title[0] != '\0')
? items[i].title
: ((items[i].dtag && items[i].dtag[0] != '\0') ? items[i].dtag : "(untitled)");
ctx.items = items;
ctx.count = items_count;
posts_print_list_item_responsive(i + 1,
items[i].kind,
posts_kind_label(items[i].kind),
label,
date_buf,
term_width);
table.columns = cols;
table.column_count = (int)(sizeof(cols) / sizeof(cols[0]));
table.row_count = items_count;
table.user_data = &ctx;
table.get_cell = posts_table_get_cell;
table.is_default = NULL;
table.prefix_len = NULL;
row = tuin_table_run(&frame, &table, &status);
if (row >= 0 && row < items_count) {
selected = row;
}
if (items_count == 0) {
tui_print("No posts found.");
}
tui_print("");
tui_print_menu_item("^_V^:iew <n>");
tui_print_menu_item("^_E^:dit <n>");
tui_print_menu_item("^_D^:elete <n>");
tui_print_menu_item("^_X^:Exit");
(void)tui_end_frame_with_prompt(">", input, (int)sizeof(input));
if (input[0] == 'b' || input[0] == 'B' ||
input[0] == 'x' || input[0] == 'X' ||
input[0] == 'q' || input[0] == 'Q') {
nt_log("No posts found.");
break;
}
if ((input[0] == 'v' || input[0] == 'V' ||
input[0] == 'e' || input[0] == 'E' ||
input[0] == 'd' || input[0] == 'D') && input[1] != '\0') {
int idx = atoi(input + 1) - 1;
if (idx < 0 || idx >= items_count) {
continue;
}
action = tuin_menu_run(&frame, &menu, &status, &action_state);
if (action < 0 || action == 3) {
break;
}
if (input[0] == 'v' || input[0] == 'V') {
posts_view_item(&items[idx]);
} else if (input[0] == 'e' || input[0] == 'E') {
posts_edit_item(&items[idx]);
} else if (input[0] == 'd' || input[0] == 'D') {
posts_delete_item(&items[idx]);
break;
}
if (selected < 0 || selected >= items_count) {
continue;
}
if (action == 0) {
posts_view_item(&items[selected]);
} else if (action == 1) {
posts_edit_item(&items[selected]);
} else if (action == 2) {
posts_delete_item(&items[selected]);
break;
}
}
@@ -555,48 +686,45 @@ static void posts_kind_menu(const char *title, const char *filter_json) {
}
void menu_posts(void) {
static const tui_view_t posts_view = {
"> Main Menu > Posts",
NULL,
static const TuiMenuItem POSTS_ITEMS[] = {
{NT_HK("S", "ecret blog (kind 30024)"), 's'},
{NT_HK("P", "ublic blog (kind 30023)"), 'p'},
{NT_HK("E", "ncrypted data (kind 30078)"), 'e'},
{NT_HK("A", "ll posts"), 'a'},
{NT_HK("X", "Exit"), 'x'},
};
char input[32];
TuiMenuState menu_state = {0};
char filter[512];
while (1) {
tui_begin_frame(&posts_view);
tui_print_menu_item("^_S^:ecret blog (kind 30024)");
tui_print_menu_item("^_P^:ublic blog (kind 30023)");
tui_print_menu_item("^_E^:ncrypted data (kind 30078)");
tui_print_menu_item("^_A^:ll posts");
tui_print_menu_item("^_X^:Exit");
TuiFrame frame = nt_frame("> Main Menu > Posts");
TuiStatus status = nt_status();
TuiMenu menu = {POSTS_ITEMS, 5};
int idx = tuin_menu_run(&frame, &menu, &status, &menu_state);
(void)tui_end_frame_with_prompt(">", input, (int)sizeof(input));
if (input[0] == 'b' || input[0] == 'B' ||
input[0] == 'x' || input[0] == 'X' ||
input[0] == 'q' || input[0] == 'Q') {
if (idx < 0 || idx == 4) {
break;
}
if (input[0] == 's' || input[0] == 'S') {
if (idx == 0) {
snprintf(filter,
sizeof(filter),
"{\"authors\":[\"%s\"],\"kinds\":[30024],\"limit\":200}",
g_state.npub_hex);
posts_kind_menu("PRIVATE BLOG", filter);
} else if (input[0] == 'p' || input[0] == 'P') {
} else if (idx == 1) {
snprintf(filter,
sizeof(filter),
"{\"authors\":[\"%s\"],\"kinds\":[30023],\"limit\":200}",
g_state.npub_hex);
posts_kind_menu("PUBLIC BLOG", filter);
} else if (input[0] == 'e' || input[0] == 'E') {
} else if (idx == 2) {
snprintf(filter,
sizeof(filter),
"{\"authors\":[\"%s\"],\"kinds\":[30078],\"limit\":200}",
g_state.npub_hex);
posts_kind_menu("ENCRYPTED DATA", filter);
} else if (input[0] == 'a' || input[0] == 'A') {
} else if (idx == 3) {
snprintf(filter,
sizeof(filter),
"{\"authors\":[\"%s\"],\"kinds\":[30023,30024,30078],\"limit\":300}",

View File

@@ -16,51 +16,58 @@ static int profile_publish_kind0(const char *content_json) {
}
void menu_profile(void) {
static const tui_view_t profile_view = {
"> Main Menu > Profile",
NULL,
static const char *fields[] = {"name", "about", "picture", "banner", "website", "lud16", "nip05"};
static const TuiMenuItem PROFILE_ITEMS[] = {
{NT_HK("M", "odify account"), 'm'},
{NT_HK("P", "ost changes and exit."), 'p'},
{"E" NT_HK("x", "it without saving"), 'x'},
};
char menu_sel[32];
TuiMenuState menu_state = {0};
char edit_buf[512];
while (1) {
TuiFrame frame = nt_frame("> Main Menu > Profile");
TuiMenu menu = {PROFILE_ITEMS, 3};
TuiStatus status = nt_status();
cJSON *meta;
const char *fields[] = {"name", "about", "picture", "banner", "website", "lud16", "nip05"};
int i;
int idx;
tui_begin_frame(&profile_view);
tuin_render_header(&frame);
nt_print_reset();
tuin_render_footer(&status);
if (g_signer.kind == NT_SIGNER_NSIGNER) {
tui_print("Signer: n_signer (@%s)", g_signer.socket_name);
nt_print("Signer: n_signer (@%s)", g_signer.socket_name);
if (g_signer.selector.has_nostr_index) {
tui_print("Signer selector: nostr_index=%d", g_signer.selector.nostr_index);
nt_print("Signer selector: nostr_index=%d", g_signer.selector.nostr_index);
} else {
tui_print("Signer selector: role=%s", g_signer.selector.role[0] ? g_signer.selector.role : "main");
nt_print("Signer selector: role=%s", g_signer.selector.role[0] ? g_signer.selector.role : "main");
}
} else if (g_signer.kind == NT_SIGNER_NSIGNER_QREXEC) {
tui_print("Signer: n_signer qrexec");
tui_print("Signer target: %s", g_signer.qrexec_target);
tui_print("Signer service: %s", g_signer.qrexec_service);
nt_print("Signer: n_signer qrexec");
nt_print("Signer target: %s", g_signer.qrexec_target);
nt_print("Signer service: %s", g_signer.qrexec_service);
if (g_signer.selector.has_nostr_index) {
tui_print("Signer selector: nostr_index=%d", g_signer.selector.nostr_index);
nt_print("Signer selector: nostr_index=%d", g_signer.selector.nostr_index);
} else {
tui_print("Signer selector: role=%s", g_signer.selector.role[0] ? g_signer.selector.role : "main");
nt_print("Signer selector: role=%s", g_signer.selector.role[0] ? g_signer.selector.role : "main");
}
} else if (g_signer.kind == NT_SIGNER_NSIGNER_URL) {
tui_print("Signer: n_signer URL");
tui_print("Signer endpoint: %s", g_signer.endpoint_url);
nt_print("Signer: n_signer URL");
nt_print("Signer endpoint: %s", g_signer.endpoint_url);
if (g_signer.selector.has_nostr_index) {
tui_print("Signer selector: nostr_index=%d", g_signer.selector.nostr_index);
nt_print("Signer selector: nostr_index=%d", g_signer.selector.nostr_index);
} else {
tui_print("Signer selector: role=%s", g_signer.selector.role[0] ? g_signer.selector.role : "main");
nt_print("Signer selector: role=%s", g_signer.selector.role[0] ? g_signer.selector.role : "main");
}
} else {
tui_print("nsecHex: %s", g_state.nsec_hex);
tui_print("nsec: %s", g_state.nsec_bech32);
nt_print("nsecHex: %s", g_state.nsec_hex);
nt_print("nsec: %s", g_state.nsec_bech32);
}
tui_print("npubHex: %s", g_state.npub_hex);
tui_print("npub: %s", g_state.npub_bech32);
tui_print("");
nt_print("npubHex: %s", g_state.npub_hex);
nt_print("npub: %s", g_state.npub_bech32);
nt_print("");
meta = cJSON_Parse(g_state.kind0_json ? g_state.kind0_json : "{}");
if (!meta) {
@@ -69,30 +76,28 @@ void menu_profile(void) {
for (i = 0; i < (int)(sizeof(fields) / sizeof(fields[0])); i++) {
cJSON *v = cJSON_GetObjectItemCaseSensitive(meta, fields[i]);
tui_print("%-18s %s", fields[i], (cJSON_IsString(v) && v->valuestring) ? v->valuestring : "");
nt_print("%-18s %s", fields[i], (cJSON_IsString(v) && v->valuestring) ? v->valuestring : "");
}
tui_print("");
tui_print_menu_item("^_M^:odify account");
tui_print_menu_item("^_P^:ost changes and exit.");
tui_print_menu_item("E^_x^:it without saving");
idx = tuin_menu_run(&frame, &menu, &status, &menu_state);
if (idx < 0 || idx == 2) {
cJSON_Delete(meta);
break;
}
(void)tui_end_frame_with_prompt(">", menu_sel, (int)sizeof(menu_sel));
if (menu_sel[0] == 'm' || menu_sel[0] == 'M') {
if (idx == 0) {
for (i = 0; i < (int)(sizeof(fields) / sizeof(fields[0])); i++) {
cJSON *v = cJSON_GetObjectItemCaseSensitive(meta, fields[i]);
const char *cur = (cJSON_IsString(v) && v->valuestring) ? v->valuestring : "";
char prompt[64];
snprintf(prompt, sizeof(prompt), "%-20s >", fields[i]);
tui_get_line(prompt, edit_buf, (int)sizeof(edit_buf));
if (edit_buf[0] != '\0') {
cJSON_DeleteItemFromObjectCaseSensitive(meta, fields[i]);
cJSON_AddStringToObject(meta, fields[i], edit_buf);
} else if (!cJSON_GetObjectItemCaseSensitive(meta, fields[i])) {
cJSON_AddStringToObject(meta, fields[i], cur);
snprintf(prompt, sizeof(prompt), "%s", fields[i]);
if (tuin_prompt(prompt, cur, edit_buf, sizeof(edit_buf)) != 0) {
continue;
}
cJSON_DeleteItemFromObjectCaseSensitive(meta, fields[i]);
cJSON_AddStringToObject(meta, fields[i], edit_buf);
}
free(g_state.kind0_json);
@@ -110,19 +115,19 @@ void menu_profile(void) {
snprintf(g_state.user_name, sizeof(g_state.user_name), "%s", name->valuestring);
}
}
}
if (menu_sel[0] == 'p' || menu_sel[0] == 'P') {
int posted = profile_publish_kind0(g_state.kind0_json ? g_state.kind0_json : "{}");
if (posted < 0) {
tui_print("Profile publish failed.");
} else if (idx == 1) {
int posted;
if (tuin_confirm("Publish profile changes?") != 1) {
cJSON_Delete(meta);
continue;
}
cJSON_Delete(meta);
tui_get_line(">", edit_buf, (int)sizeof(edit_buf));
break;
}
if (menu_sel[0] == 'x' || menu_sel[0] == 'X') {
posted = profile_publish_kind0(g_state.kind0_json ? g_state.kind0_json : "{}");
if (posted < 0) {
tuin_notice("Profile publish failed.");
} else {
nt_log("Profile published.");
}
cJSON_Delete(meta);
break;
}

View File

@@ -6,9 +6,16 @@
#include "../resources/nostr_core_lib/cjson/cJSON.h"
#include <ncurses.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
cJSON *tags;
int *tag_indices;
int count;
} relay_table_ctx_t;
static int relays_publish_kind10002(cJSON *event_obj) {
cJSON *tags;
cJSON *content;
@@ -27,92 +34,117 @@ static int relays_publish_kind10002(cJSON *event_obj) {
8000);
}
static void relays_print_nip11_summary(const char *nip11_json) {
cJSON *doc;
cJSON *name;
cJSON *description;
cJSON *software;
cJSON *version;
cJSON *pubkey;
cJSON *contact;
cJSON *supported_nips;
cJSON *limitation;
if (!nip11_json || nip11_json[0] == '\0') {
tui_print("NIP-11: (empty response)");
return;
static int relays_collect_rows(cJSON *tags, int **out_indices) {
int n;
int i;
int count = 0;
int *indices;
if (!out_indices || !cJSON_IsArray(tags)) {
return 0;
}
doc = cJSON_Parse(nip11_json);
if (!doc || !cJSON_IsObject(doc)) {
tui_print("NIP-11 (raw): %s", nip11_json);
cJSON_Delete(doc);
return;
*out_indices = NULL;
n = cJSON_GetArraySize(tags);
if (n <= 0) {
return 0;
}
name = cJSON_GetObjectItemCaseSensitive(doc, "name");
description = cJSON_GetObjectItemCaseSensitive(doc, "description");
software = cJSON_GetObjectItemCaseSensitive(doc, "software");
version = cJSON_GetObjectItemCaseSensitive(doc, "version");
pubkey = cJSON_GetObjectItemCaseSensitive(doc, "pubkey");
contact = cJSON_GetObjectItemCaseSensitive(doc, "contact");
supported_nips = cJSON_GetObjectItemCaseSensitive(doc, "supported_nips");
limitation = cJSON_GetObjectItemCaseSensitive(doc, "limitation");
tui_print("NIP-11 relay profile:");
tui_print(" Name: %s", (cJSON_IsString(name) && name->valuestring) ? name->valuestring : "(n/a)");
tui_print(" Description: %s", (cJSON_IsString(description) && description->valuestring) ? description->valuestring : "(n/a)");
tui_print(" Software: %s", (cJSON_IsString(software) && software->valuestring) ? software->valuestring : "(n/a)");
tui_print(" Version: %s", (cJSON_IsString(version) && version->valuestring) ? version->valuestring : "(n/a)");
tui_print(" Contact: %s", (cJSON_IsString(contact) && contact->valuestring) ? contact->valuestring : "(n/a)");
tui_print(" Pubkey: %s", (cJSON_IsString(pubkey) && pubkey->valuestring) ? pubkey->valuestring : "(n/a)");
if (cJSON_IsArray(supported_nips)) {
char *nips_json = cJSON_PrintUnformatted(supported_nips);
if (nips_json) {
tui_print(" Supported NIPs: %s", nips_json);
free(nips_json);
}
indices = (int *)malloc((size_t)n * sizeof(int));
if (!indices) {
return 0;
}
if (cJSON_IsObject(limitation)) {
cJSON *max_subscriptions = cJSON_GetObjectItemCaseSensitive(limitation, "max_subscriptions");
cJSON *max_message_length = cJSON_GetObjectItemCaseSensitive(limitation, "max_message_length");
cJSON *max_limit = cJSON_GetObjectItemCaseSensitive(limitation, "max_limit");
tui_print(" Limits:");
if (cJSON_IsNumber(max_subscriptions)) {
tui_print(" max_subscriptions: %d", max_subscriptions->valueint);
}
if (cJSON_IsNumber(max_message_length)) {
tui_print(" max_message_length: %d", max_message_length->valueint);
}
if (cJSON_IsNumber(max_limit)) {
tui_print(" max_limit: %d", max_limit->valueint);
for (i = 0; i < n; i++) {
cJSON *tag = cJSON_GetArrayItem(tags, i);
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
if (!cJSON_IsArray(tag) || !cJSON_IsString(t0) || !t0->valuestring || strcmp(t0->valuestring, "r") != 0) {
continue;
}
indices[count++] = i;
}
cJSON_Delete(doc);
*out_indices = indices;
return count;
}
static void relays_print_row_responsive(int term_width, int index, const char *url, const char *rw) {
if (term_width < 90) {
tui_print("[%2d] %s", index, url ? url : "");
tui_print(" %s", rw ? rw : "");
static void relays_table_get_cell(int row, int col, char *out, size_t out_size, void *user_data) {
const relay_table_ctx_t *ctx = (const relay_table_ctx_t *)user_data;
cJSON *tag;
cJSON *t1;
cJSON *t2;
const char *rw = "read write";
if (!out || out_size == 0U) {
return;
}
out[0] = '\0';
if (!ctx || row < 0 || row >= ctx->count) {
return;
}
tui_print("[%2d] %-30s %s", index, url ? url : "", rw ? rw : "");
tag = cJSON_GetArrayItem(ctx->tags, ctx->tag_indices[row]);
t1 = cJSON_GetArrayItem(tag, 1);
t2 = cJSON_GetArrayItem(tag, 2);
if (cJSON_IsString(t2) && t2->valuestring) {
rw = t2->valuestring;
}
if (col == 0) {
snprintf(out, out_size, "%d", row + 1);
} else if (col == 1) {
snprintf(out, out_size, "%s", (cJSON_IsString(t1) && t1->valuestring) ? t1->valuestring : "");
} else if (col == 2) {
snprintf(out, out_size, "%s", rw);
}
}
static void relays_show_nip11_screen(const char *relay_url) {
char *nip11 = NULL;
if (!relay_url || relay_url[0] == '\0') {
tuin_notice("Selected relay has no URL.");
return;
}
nt_log("Fetching NIP-11: %s", relay_url);
if (nt_fetch_nip11(relay_url, &nip11) == 0 && nip11) {
size_t total = strlen(relay_url) + strlen(nip11) + 32U;
char *view_text = (char *)malloc(total);
if (!view_text) {
free(nip11);
tuin_notice("Out of memory while preparing NIP-11 view.");
return;
}
snprintf(view_text, total, "Relay: %s\n\n%s", relay_url, nip11);
nt_pager_show_text("> Main Menu > Relays > NIP-11", view_text);
free(view_text);
} else {
tuin_notice("NIP-11 fetch failed.");
}
free(nip11);
}
void menu_relays(void) {
static const tui_view_t relays_view = {
"> Main Menu > Relays",
NULL,
static const TuiMenuItem ACTION_ITEMS[] = {
{NT_HK("A", "dd relay"), 'a'},
{NT_HK("D", "elete selected relay"), 'd'},
{NT_HK("M", "odify selected relay"), 'm'},
{NT_HK("N", "IP-11 selected relay"), 'n'},
{NT_HK("P", "ost changes and exit"), 'p'},
{"E" NT_HK("x", "it without saving"), 'x'},
};
cJSON *working = NULL;
cJSON *tags = NULL;
char input[512];
TuiMenuState action_state = {0};
int selected_tag_index = -1;
working = g_state.kind10002_json ? cJSON_Parse(g_state.kind10002_json) : NULL;
if (!working) {
@@ -130,75 +162,78 @@ void menu_relays(void) {
}
while (1) {
int i;
int n;
int term_width = 0;
int *row_indices = NULL;
int row_count = relays_collect_rows(tags, &row_indices);
relay_table_ctx_t ctx;
TuiFrame frame = nt_frame("> Main Menu > Relays");
TuiStatus status = nt_status();
TuiColumn cols[] = {
{"#", 4, 1},
{"URL", 0, 0},
{"R/W", 12, 0},
};
TuiTable table;
int selected_row;
TuiMenu action_menu = {ACTION_ITEMS, (int)(sizeof(ACTION_ITEMS) / sizeof(ACTION_ITEMS[0]))};
int action;
tui_begin_frame(&relays_view);
tui_get_terminal_size(&term_width, NULL);
ctx.tags = tags;
ctx.tag_indices = row_indices;
ctx.count = row_count;
n = cJSON_GetArraySize(tags);
for (i = 0; i < n; i++) {
cJSON *tag = cJSON_GetArrayItem(tags, i);
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
cJSON *t2 = cJSON_GetArrayItem(tag, 2);
const char *rw = "read write";
table.columns = cols;
table.column_count = (int)(sizeof(cols) / sizeof(cols[0]));
table.row_count = row_count;
table.user_data = &ctx;
table.get_cell = relays_table_get_cell;
table.is_default = NULL;
table.prefix_len = NULL;
if (!cJSON_IsArray(tag) || !cJSON_IsString(t0) || !t0->valuestring || strcmp(t0->valuestring, "r") != 0) {
continue;
}
if (cJSON_IsString(t2) && t2->valuestring) {
rw = t2->valuestring;
}
relays_print_row_responsive(term_width,
i + 1,
(cJSON_IsString(t1) && t1->valuestring) ? t1->valuestring : "",
rw);
selected_row = tuin_table_run(&frame, &table, &status);
if (selected_row >= 0 && selected_row < row_count) {
selected_tag_index = row_indices[selected_row];
}
tui_print("");
tui_print_menu_item("^_A^:dd relay");
tui_print_menu_item("^_D^:elete relay");
tui_print_menu_item("^_M^:odify relay");
tui_print_menu_item("^_P^:ost changes and exit.");
tui_print_menu_item("E^_x^:it without saving");
free(row_indices);
(void)tui_end_frame_with_prompt(">", input, (int)sizeof(input));
action = tuin_menu_run(&frame, &action_menu, &status, &action_state);
if (action < 0 || action == 5) {
break;
}
if (input[0] == 'a' || input[0] == 'A') {
if (action == 0) {
char relay_url[256];
char yn[32];
char read_yn[32];
char write_yn[32];
char *nip11 = NULL;
tui_begin_frame(&relays_view);
tui_print("Add relay");
(void)tui_end_frame_with_prompt("Relay URL >", relay_url, (int)sizeof(relay_url));
if (relay_url[0] == '\0') {
relay_url[0] = '\0';
if (tuin_prompt("Relay URL >", "", relay_url, sizeof(relay_url)) != 0 || relay_url[0] == '\0') {
continue;
}
tui_begin_frame(&relays_view);
tui_print("Gathering relay data ...");
if (nt_fetch_nip11(relay_url, &nip11) == 0 && nip11) {
relays_print_nip11_summary(nip11);
free(nip11);
relays_show_nip11_screen(relay_url);
} else {
tui_print("NIP-11 fetch failed.");
tuin_notice("NIP-11 fetch failed.");
}
free(nip11);
yn[0] = '\0';
if (tuin_prompt("Add relay [y/n] >", "", yn, sizeof(yn)) != 0) {
continue;
}
(void)tui_end_frame_with_prompt("Add relay [y/n] >", yn, (int)sizeof(yn));
if (yn[0] == 'y' || yn[0] == 'Y') {
cJSON *tag = cJSON_CreateArray();
cJSON_AddItemToArray(tag, cJSON_CreateString("r"));
cJSON_AddItemToArray(tag, cJSON_CreateString(relay_url));
(void)tui_end_frame_with_prompt("Read from relay [y/n] >", read_yn, (int)sizeof(read_yn));
(void)tui_end_frame_with_prompt("Write to relay [y/n] >", write_yn, (int)sizeof(write_yn));
read_yn[0] = '\0';
write_yn[0] = '\0';
(void)tuin_prompt("Read from relay [y/n] >", "", read_yn, sizeof(read_yn));
(void)tuin_prompt("Write to relay [y/n] >", "", write_yn, sizeof(write_yn));
if ((read_yn[0] == 'n' || read_yn[0] == 'N') && (write_yn[0] == 'y' || write_yn[0] == 'Y')) {
cJSON_AddItemToArray(tag, cJSON_CreateString("write"));
@@ -207,50 +242,41 @@ void menu_relays(void) {
}
cJSON_AddItemToArray(tags, tag);
}
} else if (input[0] == 'd' || input[0] == 'D') {
char numbuf[32];
char yn[32];
int idx;
} else if (action == 1) {
cJSON *tag;
cJSON *url;
tui_begin_frame(&relays_view);
(void)tui_end_frame_with_prompt("relay to delete >", numbuf, (int)sizeof(numbuf));
idx = atoi(numbuf) - 1;
if (idx < 0 || idx >= cJSON_GetArraySize(tags)) {
if (selected_tag_index < 0 || selected_tag_index >= cJSON_GetArraySize(tags)) {
tuin_notice("Select a relay row first.");
continue;
}
tag = cJSON_GetArrayItem(tags, idx);
tag = cJSON_GetArrayItem(tags, selected_tag_index);
url = cJSON_GetArrayItem(tag, 1);
(void)tui_end_frame_with_prompt("Delete selected relay [y/n] >", yn, (int)sizeof(yn));
if (yn[0] == 'y' || yn[0] == 'Y') {
cJSON_DeleteItemFromArray(tags, idx);
tui_begin_frame(&relays_view);
if (tuin_confirm("Delete selected relay?") == 1) {
cJSON_DeleteItemFromArray(tags, selected_tag_index);
selected_tag_index = -1;
if (cJSON_IsString(url) && url->valuestring) {
tui_print("Deleted %s", url->valuestring);
char msg[512];
snprintf(msg, sizeof(msg), "Deleted %s", url->valuestring);
tuin_notice(msg);
} else {
tui_print("Deleted relay.");
tuin_notice("Deleted relay.");
}
(void)tui_end_frame_with_prompt("Press Enter to continue >", input, (int)sizeof(input));
}
} else if (input[0] == 'm' || input[0] == 'M') {
char numbuf[32];
} else if (action == 2) {
char read_yn[32];
char write_yn[32];
int idx;
cJSON *old_tag;
cJSON *url;
cJSON *new_tag;
tui_begin_frame(&relays_view);
(void)tui_end_frame_with_prompt("# relay to modify >", numbuf, (int)sizeof(numbuf));
idx = atoi(numbuf) - 1;
if (idx < 0 || idx >= cJSON_GetArraySize(tags)) {
if (selected_tag_index < 0 || selected_tag_index >= cJSON_GetArraySize(tags)) {
tuin_notice("Select a relay row first.");
continue;
}
old_tag = cJSON_GetArrayItem(tags, idx);
old_tag = cJSON_GetArrayItem(tags, selected_tag_index);
url = cJSON_GetArrayItem(old_tag, 1);
if (!cJSON_IsString(url) || !url->valuestring || url->valuestring[0] == '\0') {
continue;
@@ -260,8 +286,10 @@ void menu_relays(void) {
cJSON_AddItemToArray(new_tag, cJSON_CreateString("r"));
cJSON_AddItemToArray(new_tag, cJSON_CreateString(url->valuestring));
(void)tui_end_frame_with_prompt("Read from relay [y/n]?", read_yn, (int)sizeof(read_yn));
(void)tui_end_frame_with_prompt("Write to relay [y/n]?", write_yn, (int)sizeof(write_yn));
read_yn[0] = '\0';
write_yn[0] = '\0';
(void)tuin_prompt("Read from relay [y/n]?", "", read_yn, sizeof(read_yn));
(void)tuin_prompt("Write to relay [y/n]?", "", write_yn, sizeof(write_yn));
if ((read_yn[0] == 'n' || read_yn[0] == 'N') && (write_yn[0] == 'y' || write_yn[0] == 'Y')) {
cJSON_AddItemToArray(new_tag, cJSON_CreateString("write"));
@@ -269,8 +297,23 @@ void menu_relays(void) {
cJSON_AddItemToArray(new_tag, cJSON_CreateString("read"));
}
cJSON_ReplaceItemInArray(tags, idx, new_tag);
} else if (input[0] == 'p' || input[0] == 'P') {
cJSON_ReplaceItemInArray(tags, selected_tag_index, new_tag);
} else if (action == 3) {
cJSON *tag;
cJSON *url;
if (selected_tag_index < 0 || selected_tag_index >= cJSON_GetArraySize(tags)) {
tuin_notice("Select a relay row first.");
continue;
}
tag = cJSON_GetArrayItem(tags, selected_tag_index);
url = cJSON_GetArrayItem(tag, 1);
if (!cJSON_IsString(url) || !url->valuestring) {
tuin_notice("Selected relay has no URL.");
continue;
}
relays_show_nip11_screen(url->valuestring);
} else if (action == 4) {
int posted = relays_publish_kind10002(working);
char *new_json = cJSON_PrintUnformatted(working);
@@ -280,16 +323,11 @@ void menu_relays(void) {
state_parse_relay_list();
}
tui_begin_frame(&relays_view);
if (posted < 0) {
tui_print("Relay list publish failed.");
tuin_notice("Relay list publish failed.");
} else {
tui_print("Relay list published.");
nt_log("Relay list published.");
}
(void)tui_end_frame_with_prompt("Press Enter to exit >", input, (int)sizeof(input));
break;
} else if (input[0] == 'x' || input[0] == 'X' ||
input[0] == 'q' || input[0] == 'Q') {
break;
}
}

View File

@@ -17,6 +17,11 @@ typedef struct {
int done;
} todo_item_t;
typedef struct {
const todo_item_t *items;
int count;
} todo_table_ctx_t;
static char *todo_strdup(const char *s) {
size_t n;
char *out;
@@ -255,106 +260,6 @@ static int todo_load_remote(todo_item_t **items_out, int *count_out) {
return rc;
}
static void todo_show_list(const todo_item_t *items, int count) {
int i;
tui_print("TODO LIST\n");
for (i = 0; i < count; i++) {
tui_print("%2d. [%c] %s", i + 1, items[i].done ? 'x' : ' ', items[i].text ? items[i].text : "");
}
if (count == 0) {
tui_print("(empty)");
}
tui_print("");
}
static void todo_add_item(todo_item_t **items, int *count) {
char input[1024];
tui_get_line("new item >", input, (int)sizeof(input));
if (input[0] == '\0') {
return;
}
if (todo_push_item(items, count, input, 0) != 0) {
tui_print("Failed to add item.");
}
}
static void todo_toggle_item(todo_item_t *items, int count) {
char input[64];
int idx;
if (!items || count <= 0) {
return;
}
tui_get_line("toggle number >", input, (int)sizeof(input));
idx = todo_parse_index_1based(input, count);
if (idx < 0) {
tui_print("Invalid number.");
return;
}
items[idx].done = !items[idx].done;
}
static void todo_delete_item(todo_item_t *items, int *count) {
char input[64];
char yn[8];
int idx;
int i;
if (!items || !count || *count <= 0) {
return;
}
tui_get_line("delete number >", input, (int)sizeof(input));
idx = todo_parse_index_1based(input, *count);
if (idx < 0) {
tui_print("Invalid number.");
return;
}
tui_get_line("Delete selected item [y/n] >", yn, (int)sizeof(yn));
if (!(yn[0] == 'y' || yn[0] == 'Y')) {
tui_print("Delete canceled.");
return;
}
free(items[idx].text);
for (i = idx; i < (*count - 1); i++) {
items[i] = items[i + 1];
}
(*count)--;
}
static void todo_reorder_swap(todo_item_t *items, int count) {
char input_a[64];
char input_b[64];
int a;
int b;
todo_item_t tmp;
if (!items || count < 2) {
return;
}
tui_get_line("first number >", input_a, (int)sizeof(input_a));
tui_get_line("second number >", input_b, (int)sizeof(input_b));
a = todo_parse_index_1based(input_a, count);
b = todo_parse_index_1based(input_b, count);
if (a < 0 || b < 0 || a == b) {
tui_print("Invalid numbers.");
return;
}
tmp = items[a];
items[a] = items[b];
items[b] = tmp;
}
static int todo_publish(const todo_item_t *items, int count) {
char *json = NULL;
char *cipher = NULL;
@@ -363,19 +268,19 @@ static int todo_publish(const todo_item_t *items, int count) {
json = todo_items_to_json(items, count);
if (!json) {
tui_print("Failed to serialize todo list.");
tuin_notice("Failed to serialize todo list.");
return -1;
}
if (todo_encrypt_for_self(json, &cipher) != 0) {
tui_print("Failed to encrypt todo list.");
tuin_notice("Failed to encrypt todo list.");
free(json);
return -1;
}
tags = todo_make_d_tag();
if (!tags) {
tui_print("Failed to build todo tags.");
tuin_notice("Failed to build todo tags.");
free(json);
free(cipher);
return -1;
@@ -387,22 +292,49 @@ static int todo_publish(const todo_item_t *items, int count) {
free(cipher);
if (posted < 0) {
tui_print("Todo publish failed.");
tuin_notice("Todo publish failed.");
return -1;
}
tui_print("Todo saved.");
nt_log("Todo saved.");
return 0;
}
static void todo_table_get_cell(int row, int col, char *out, size_t out_size, void *user_data) {
const todo_table_ctx_t *ctx = (const todo_table_ctx_t *)user_data;
if (!out || out_size == 0U) {
return;
}
out[0] = '\0';
if (!ctx || row < 0 || row >= ctx->count) {
return;
}
if (col == 0) {
snprintf(out, out_size, "%d", row + 1);
} else if (col == 1) {
snprintf(out, out_size, "%c", ctx->items[row].done ? 'x' : ' ');
} else if (col == 2) {
snprintf(out, out_size, "%s", ctx->items[row].text ? ctx->items[row].text : "");
}
}
void menu_todo(void) {
static const tui_view_t todo_view = {
"> Main Menu > Todo",
NULL,
static const TuiMenuItem ACTION_ITEMS[] = {
{NT_HK("A", "dd item"), 'a'},
{NT_HK("C", "omplete/toggle selected item"), 'c'},
{NT_HK("D", "elete selected item"), 'd'},
{NT_HK("R", "eorder (swap two)"), 'r'},
{NT_HK("P", "ost/save changes"), 'p'},
{"E" NT_HK("x", "it without saving"), 'x'},
};
todo_item_t *items = NULL;
int count = 0;
char input[32];
int selected = -1;
TuiMenuState action_state = {0};
if (todo_load_remote(&items, &count) != 0) {
items = NULL;
@@ -410,43 +342,107 @@ void menu_todo(void) {
}
while (1) {
tui_begin_frame(&todo_view);
if (count == 0) {
tui_print("TODO LIST");
tui_print("(empty)");
tui_print("");
} else {
todo_show_list(items, count);
TuiFrame frame = nt_frame("> Main Menu > Todo");
TuiStatus status = nt_status();
TuiColumn cols[] = {
{"#", 4, 1},
{"Done", 6, 0},
{"Task", 0, 0},
};
todo_table_ctx_t ctx;
TuiTable table;
TuiMenu menu = {ACTION_ITEMS, 6};
int row;
int action;
ctx.items = items;
ctx.count = count;
table.columns = cols;
table.column_count = (int)(sizeof(cols) / sizeof(cols[0]));
table.row_count = count;
table.user_data = &ctx;
table.get_cell = todo_table_get_cell;
table.is_default = NULL;
table.prefix_len = NULL;
row = tuin_table_run(&frame, &table, &status);
if (row >= 0 && row < count) {
selected = row;
}
tui_print_menu_item("^_A^:dd item");
tui_print_menu_item("^_C^:omplete/toggle item");
tui_print_menu_item("^_D^:elete item");
tui_print_menu_item("^_R^:eorder (swap two)");
tui_print_menu_item("^_P^:ost/save changes");
tui_print_menu_item("E^_x^:it without saving");
if (count == 0) {
nt_print_reset();
nt_print("TODO LIST");
nt_print("(empty)");
}
(void)tui_end_frame_with_prompt(">", input, (int)sizeof(input));
if (input[0] == 'x' || input[0] == 'X' ||
input[0] == 'q' || input[0] == 'Q') {
action = tuin_menu_run(&frame, &menu, &status, &action_state);
if (action < 0 || action == 5) {
break;
}
if (input[0] == 'a' || input[0] == 'A') {
todo_add_item(&items, &count);
} else if (input[0] == 'c' || input[0] == 'C') {
todo_toggle_item(items, count);
} else if (input[0] == 'd' || input[0] == 'D') {
todo_delete_item(items, &count);
} else if (input[0] == 'r' || input[0] == 'R') {
todo_reorder_swap(items, count);
} else if (input[0] == 'p' || input[0] == 'P') {
char hold[8];
if (action == 0) {
char input[1024];
input[0] = '\0';
if (tuin_prompt("new item >", "", input, sizeof(input)) == 0 && input[0] != '\0') {
if (todo_push_item(&items, &count, input, 0) != 0) {
tuin_notice("Failed to add item.");
}
}
} else if (action == 1) {
if (selected < 0 || selected >= count) {
tuin_notice("Select an item first.");
continue;
}
items[selected].done = !items[selected].done;
} else if (action == 2) {
int i;
if (selected < 0 || selected >= count) {
tuin_notice("Select an item first.");
continue;
}
if (tuin_confirm("Delete selected item?") != 1) {
nt_log("Delete canceled.");
continue;
}
free(items[selected].text);
for (i = selected; i < (count - 1); i++) {
items[i] = items[i + 1];
}
count--;
if (selected >= count) {
selected = count - 1;
}
} else if (action == 3) {
char input_a[64];
char input_b[64];
int a;
int b;
todo_item_t tmp;
if (!items || count < 2) {
continue;
}
input_a[0] = '\0';
input_b[0] = '\0';
(void)tuin_prompt("first number >", "", input_a, sizeof(input_a));
(void)tuin_prompt("second number >", "", input_b, sizeof(input_b));
a = todo_parse_index_1based(input_a, count);
b = todo_parse_index_1based(input_b, count);
if (a < 0 || b < 0 || a == b) {
tuin_notice("Invalid numbers.");
continue;
}
tmp = items[a];
items[a] = items[b];
items[b] = tmp;
} else if (action == 4) {
(void)todo_publish(items, count);
tui_begin_frame(&todo_view);
tui_print("Todo publish attempted.");
(void)tui_end_frame_with_prompt("Press Enter to continue >", hold, (int)sizeof(hold));
nt_log("Todo publish attempted.");
}
}

View File

@@ -8,28 +8,31 @@
#include <string.h>
void menu_tweet(void) {
static const tui_view_t tweet_view = {
"> Main Menu > Tweet",
NULL,
};
TuiFrame frame = nt_frame("> Main Menu > Tweet");
TuiStatus status = nt_status();
char text[4096];
int posted;
tui_begin_frame(&tweet_view);
tui_print("Compose a short post.");
tui_print("");
(void)tui_end_frame_with_prompt("tweet >", text, (int)sizeof(text));
if (text[0] == '\0') {
tuin_render_header(&frame);
nt_print_reset();
tuin_render_footer(&status);
nt_log("Compose a short post.");
nt_log("");
if (tuin_prompt("tweet", "", text, sizeof(text)) != 0 || text[0] == '\0') {
return;
}
if (tuin_confirm("Publish this tweet?") != 1) {
return;
}
posted = publish_signed_event_with_report("Tweet", 1, text, NULL, 8000);
tui_begin_frame(&tweet_view);
if (posted < 0) {
tui_print("Tweet publish failed.");
tuin_notice("Tweet publish failed.");
} else {
tui_print("Tweet published.");
nt_log("Tweet published.");
}
(void)tui_end_frame_with_prompt("Press Enter to exit >", text, (int)sizeof(text));
}

View File

@@ -9,6 +9,19 @@
#include <string.h>
#include <time.h>
typedef struct {
char *content;
} NtEditorSpawnCtx;
static int nt_editor_spawn(void *user) {
NtEditorSpawnCtx *ctx = (NtEditorSpawnCtx *)user;
if (!ctx) {
return -1;
}
ctx->content = editor_launch(NULL);
return 0;
}
static cJSON *write_make_blog_tags(const char *d_tag_value, const char *title_opt) {
cJSON *tags = cJSON_CreateArray();
cJSON *d_tag;
@@ -39,107 +52,126 @@ static cJSON *write_make_blog_tags(const char *d_tag_value, const char *title_op
}
void menu_write(void) {
static const tui_view_t write_view = {
"> Main Menu > Write",
NULL,
static const TuiMenuItem WRITE_ITEMS[] = {
{NT_HK("T", "weet it"), 't'},
{NT_HK("B", "log post (kind 30023)"), 'b'},
{NT_HK("D", "iary entry (kind 30024)"), 'd'},
{NT_HK("X", "Exit without saving"), 'x'},
};
TuiFrame frame = nt_frame("> Main Menu > Write");
TuiStatus status = nt_status();
TuiMenu menu = {WRITE_ITEMS, 4};
TuiMenuState menu_state = {0};
NtEditorSpawnCtx ctx = {0};
char *content;
char input[64];
int idx;
tui_begin_frame(&write_view);
tui_print("Launching external editor...");
content = editor_launch(NULL);
if (!content) {
tui_begin_frame(&write_view);
tui_print("No content created.");
(void)tui_end_frame_with_prompt("Press Enter to exit >", input, (int)sizeof(input));
tuin_render_header(&frame);
nt_print_reset();
tuin_render_footer(&status);
nt_log("Launching external editor...");
if (nt_run_external_editor(nt_editor_spawn, &ctx) != 0) {
tuin_notice("Failed to launch external editor.");
return;
}
tui_begin_frame(&write_view);
tui_print("Content length: %d bytes", (int)strlen(content));
tui_print("");
tui_print_menu_item("^_T^:weet it");
tui_print_menu_item("^_B^:log post (kind 30023)");
tui_print_menu_item("^_D^:iary entry (kind 30024)");
tui_print_menu_item("^_X^:Exit without saving");
content = ctx.content;
if (!content) {
tuin_notice("No content created.");
return;
}
(void)tui_end_frame_with_prompt(">", input, (int)sizeof(input));
nt_print_reset();
nt_log("Content length: %d bytes", (int)strlen(content));
if (input[0] == 't' || input[0] == 'T') {
int posted = publish_signed_event_with_report("Tweet", 1, content, NULL, 8000);
tui_begin_frame(&write_view);
if (posted < 0) {
tui_print("Tweet publish failed.");
} else {
tui_print("Tweet published.");
idx = tuin_menu_run(&frame, &menu, &status, &menu_state);
if (idx == 0) {
int posted;
if (tuin_confirm("Publish as tweet?") == 1) {
posted = publish_signed_event_with_report("Tweet", 1, content, NULL, 8000);
if (posted < 0) {
tuin_notice("Tweet publish failed.");
} else {
nt_log("Tweet published.");
}
}
} else if (input[0] == 'b' || input[0] == 'B') {
} else if (idx == 1) {
char title[256];
char slug[256];
const char *d_val;
cJSON *tags;
int posted;
tui_begin_frame(&write_view);
(void)tui_end_frame_with_prompt("title >", title, (int)sizeof(title));
(void)tui_end_frame_with_prompt("slug (d tag) [empty uses title] >", slug, (int)sizeof(slug));
if (tuin_prompt("title", "", title, sizeof(title)) != 0) {
free(content);
return;
}
if (tuin_prompt("slug (d tag) [empty uses title]", "", slug, sizeof(slug)) != 0) {
free(content);
return;
}
if (tuin_confirm("Publish as blog post?") != 1) {
free(content);
return;
}
d_val = (slug[0] != '\0') ? slug : title;
tags = write_make_blog_tags(d_val, title);
tui_begin_frame(&write_view);
if (!tags) {
tui_print("Failed to build blog tags.");
tuin_notice("Failed to build blog tags.");
} else {
posted = publish_signed_event_with_report("Blog post", 30023, content, tags, 8000);
cJSON_Delete(tags);
if (posted < 0) {
tui_print("Blog publish failed.");
tuin_notice("Blog publish failed.");
} else {
tui_print("Blog post published.");
nt_log("Blog post published.");
}
}
} else if (input[0] == 'd' || input[0] == 'D') {
} else if (idx == 2) {
char date_d[16];
time_t now = time(NULL);
struct tm tmv;
cJSON *tags;
int posted;
memset(&tmv, 0, sizeof(tmv));
tui_begin_frame(&write_view);
if (!localtime_r(&now, &tmv)) {
tui_print("Failed to get local date.");
if (tuin_confirm("Publish as diary entry?") != 1) {
free(content);
(void)tui_end_frame_with_prompt("Press Enter to exit >", input, (int)sizeof(input));
return;
}
memset(&tmv, 0, sizeof(tmv));
if (!localtime_r(&now, &tmv)) {
free(content);
tuin_notice("Failed to get local date.");
return;
}
if (strftime(date_d, sizeof(date_d), "%Y%m%d", &tmv) == 0) {
tui_print("Failed to format diary date.");
free(content);
(void)tui_end_frame_with_prompt("Press Enter to exit >", input, (int)sizeof(input));
tuin_notice("Failed to format diary date.");
return;
}
tags = write_make_blog_tags(date_d, NULL);
if (!tags) {
tui_print("Failed to build diary tags.");
tuin_notice("Failed to build diary tags.");
} else {
posted = publish_signed_event_with_report("Diary", 30024, content, tags, 8000);
cJSON_Delete(tags);
if (posted < 0) {
tui_print("Diary publish failed.");
tuin_notice("Diary publish failed.");
} else {
tui_print("Diary entry published.");
nt_log("Diary entry published.");
}
}
} else {
tui_begin_frame(&write_view);
tui_print("Discarded.");
nt_log("Discarded.");
}
free(content);
(void)tui_end_frame_with_prompt("Press Enter to exit >", input, (int)sizeof(input));
}

208
src/nt_tui_adapter.c Normal file
View File

@@ -0,0 +1,208 @@
#include "tui.h"
#include <ncurses.h>
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
#include <time.h>
#define NT_LOG_MAX 512
typedef struct {
char text[512];
} NtLogEntry;
static int g_body_row = 0;
static char g_app_name[128] = "NOSTR TERMINAL";
static char g_app_version[64] = "";
static char g_user[256] = "";
static NtLogEntry g_log_entries[NT_LOG_MAX];
static int g_log_head = 0;
static int g_log_count = 0;
static WINDOW *nt_body_window(void) {
return (WINDOW *)tuin_body_window();
}
void nt_print(const char *fmt, ...) {
WINDOW *body = nt_body_window();
char line[8192];
va_list ap;
int h = 0;
int w = 0;
if (!body || !fmt) {
return;
}
getmaxyx(body, h, w);
(void)w;
if (h <= 0) {
return;
}
if (g_body_row < 0) {
g_body_row = 0;
}
if (g_body_row >= h) {
g_body_row = h - 1;
}
va_start(ap, fmt);
vsnprintf(line, sizeof(line), fmt, ap);
va_end(ap);
mvwprintw(body, g_body_row, 0, "%s", line);
g_body_row++;
if (g_body_row >= h) {
g_body_row = h - 1;
}
wnoutrefresh(body);
doupdate();
}
void nt_print_reset(void) {
WINDOW *body = nt_body_window();
if (!body) {
return;
}
werase(body);
g_body_row = 0;
wnoutrefresh(body);
doupdate();
}
int nt_run_external_editor(int (*spawn)(void *user), void *user) {
int result = -1;
if (!spawn) {
return -1;
}
tuin_cleanup();
result = spawn(user);
tuin_init();
return result;
}
static const char *nt_log_latest(void) {
int idx;
if (g_log_count <= 0) {
return "";
}
idx = (g_log_head - 1 + NT_LOG_MAX) % NT_LOG_MAX;
return g_log_entries[idx].text;
}
TuiFrame nt_frame(const char *breadcrumb) {
static char composed_title[512];
TuiFrame frame;
if (g_user[0] != '\0') {
snprintf(composed_title, sizeof(composed_title), "%s - %s - %s", g_app_name, g_app_version, g_user);
} else {
snprintf(composed_title, sizeof(composed_title), "%s - %s", g_app_name, g_app_version);
}
frame.app_name = composed_title;
frame.app_version = "";
frame.breadcrumb = breadcrumb;
return frame;
}
TuiStatus nt_status(void) {
TuiStatus status;
status.text = nt_log_latest();
return status;
}
void nt_set_app_info(const char *name, const char *version) {
snprintf(g_app_name, sizeof(g_app_name), "%s", (name && name[0] != '\0') ? name : "NOSTR TERMINAL");
snprintf(g_app_version, sizeof(g_app_version), "%s", (version && version[0] != '\0') ? version : "");
}
void nt_set_window_title(const char *title) {
if (!title) {
return;
}
fprintf(stderr, "\x1b]2;%s\x07", title);
fflush(stderr);
}
void nt_set_user(const char *user) {
snprintf(g_user, sizeof(g_user), "%s", (user && user[0] != '\0') ? user : "");
}
void nt_log(const char *fmt, ...) {
char msg[384];
char ts[16];
time_t now;
struct tm tm_now;
va_list ap;
int idx;
if (!fmt) {
return;
}
va_start(ap, fmt);
vsnprintf(msg, sizeof(msg), fmt, ap);
va_end(ap);
now = time(NULL);
if (localtime_r(&now, &tm_now) != NULL) {
snprintf(ts, sizeof(ts), "[%02d:%02d:%02d]",
tm_now.tm_hour, tm_now.tm_min, tm_now.tm_sec);
} else {
snprintf(ts, sizeof(ts), "[--:--:--]");
}
idx = g_log_head;
snprintf(g_log_entries[idx].text, sizeof(g_log_entries[idx].text), "%s %s", ts, msg);
g_log_head = (g_log_head + 1) % NT_LOG_MAX;
if (g_log_count < NT_LOG_MAX) {
g_log_count++;
}
}
void nt_log_show(void) {
TuiFrame frame = nt_frame("> Log");
TuiStatus status = {"Up/Down PgUp/PgDn Esc to return"};
static char log_text[NT_LOG_MAX * 520];
int pos = 0;
int i;
log_text[0] = '\0';
for (i = 0; i < g_log_count; ++i) {
int idx = (g_log_head - g_log_count + i + NT_LOG_MAX) % NT_LOG_MAX;
int n = snprintf(log_text + pos, sizeof(log_text) - (size_t)pos, "%s%s",
g_log_entries[idx].text,
(i == g_log_count - 1) ? "" : "\n");
if (n < 0) {
break;
}
if ((size_t)n >= sizeof(log_text) - (size_t)pos) {
pos = (int)sizeof(log_text) - 1;
break;
}
pos += n;
}
tuin_pager_run(&frame, log_text, &status);
}
void nt_log_clear(void) {
g_log_head = 0;
g_log_count = 0;
}
void nt_pager_show_text(const char *breadcrumb, const char *text) {
TuiFrame frame = nt_frame(breadcrumb);
TuiStatus status = {"Up/Down PgUp/PgDn Esc to return"};
tuin_pager_run(&frame, text ? text : "", &status);
}

View File

@@ -28,15 +28,15 @@ int publish_signed_event_with_report(const char *label,
if (signer_create_and_sign(kind, content ? content : "", tags, time(NULL), &event_json) != 0 ||
!event_json) {
tui_print("%s: failed to serialize event.", label);
nt_log("%s: failed to serialize event.", label);
return -1;
}
write_relays = state_get_write_relays(&relay_count);
tui_print("%s (kind %d)", label, kind);
tui_print("Event JSON:");
tui_print("%s", event_json);
tui_print("Posting to %d relay(s)...", relay_count);
nt_log("%s (kind %d)", label, kind);
nt_log("Event JSON:");
nt_log("%s", event_json);
nt_log("Posting to %d relay(s)...", relay_count);
accepted_count = nt_publish_detailed(event_json,
write_relays,
@@ -48,7 +48,7 @@ int publish_signed_event_with_report(const char *label,
free(event_json);
if (result_count <= 0) {
tui_print("No relay attempts were made.");
nt_log("No relay attempts were made.");
return accepted_count;
}
@@ -57,14 +57,14 @@ int publish_signed_event_with_report(const char *label,
const char *response = results[i].response ? results[i].response : "(no response)";
if (results[i].posted) {
tui_print("[OK] %s", relay);
nt_log("[OK] %s", relay);
} else {
tui_print("[FAIL] %s", relay);
tui_print(" %s", response);
nt_log("[FAIL] %s", relay);
nt_log(" %s", response);
}
}
tui_print("Accepted by %d/%d relay(s).", accepted_count, result_count);
nt_log("Accepted by %d/%d relay(s).", accepted_count, result_count);
nt_publish_results_free(results, result_count);
return accepted_count;
}