feat(tui): Enter sends chat to the selected friend
Composer Enter only ran slash commands, so typed messages were dropped. Wire Node::send, keep /commands, reject unknown /foo as not-chat.
This commit is contained in:
parent
c141eb45b8
commit
c4ccca7e8c
4 changed files with 105 additions and 13 deletions
|
|
@ -110,7 +110,7 @@ or `cargo run --release`.
|
||||||
|
|
||||||
On start you are prompted for a store passphrase (echo off, like other CLI passwords) or set `ONIONWIRE_STORE_PASSPHRASE`. Empty passphrase is rejected; a wrong passphrase does not open chat. Then you should see `onionwire: bootstrapping Arti…` on stderr. Directory bootstrap is usually under a minute; the onion is ready once a probe connect works (combined Arti status may still say Bootstrapping). Fail closed at 360s. Data lives in `ONIONWIRE_HOME` if set, otherwise `~/.local/share/onionwire/` (`onionwire.db` + Arti state, mode 0700). First open creates an ed25519 identity key. That key **is** you.
|
On start you are prompted for a store passphrase (echo off, like other CLI passwords) or set `ONIONWIRE_STORE_PASSPHRASE`. Empty passphrase is rejected; a wrong passphrase does not open chat. Then you should see `onionwire: bootstrapping Arti…` on stderr. Directory bootstrap is usually under a minute; the onion is ready once a probe connect works (combined Arti status may still say Bootstrapping). Fail closed at 360s. Data lives in `ONIONWIRE_HOME` if set, otherwise `~/.local/share/onionwire/` (`onionwire.db` + Arti state, mode 0700). First open creates an ed25519 identity key. That key **is** you.
|
||||||
|
|
||||||
Then `F2` to share your invite, `F3` to paste a friend’s. Mouse-select the `onionwire:v1:…` line to copy.
|
Then `F2` to share your invite, `F3` to paste a friend’s. Mouse-select the `onionwire:v1:…` line to copy. Highlight them in the roster, type in the composer, Enter to send. Fail closed: if their onion is down, send fails — no outbox.
|
||||||
|
|
||||||
## Friends are keys
|
## Friends are keys
|
||||||
|
|
||||||
|
|
@ -131,7 +131,7 @@ Focus starts on the composer so typing works immediately. `Tab` cycles panes; `j
|
||||||
| `F3` | Paste a friend’s invite |
|
| `F3` | Paste a friend’s invite |
|
||||||
| `F4` | Rotate **onion** (locator only) |
|
| `F4` | Rotate **onion** (locator only) |
|
||||||
| `F5` | Selected friend’s profile (`/who`) |
|
| `F5` | Selected friend’s profile (`/who`) |
|
||||||
| Enter | Run `/wipe`, `/wipe-all`, `/profile`, `/who`, `/pay`, `/tip`, `/backup`, `/restore` from the composer |
|
| Enter | Send chat to the selected friend, or run a `/command` |
|
||||||
| `Esc` | Close overlay / back to Main / clear composer |
|
| `Esc` | Close overlay / back to Main / clear composer |
|
||||||
| `Ctrl-Q` | Quit: type `CLEAR`+Enter to wipe history, `QUIT`+Enter to leave it, Esc to stay |
|
| `Ctrl-Q` | Quit: type `CLEAR`+Enter to wipe history, `QUIT`+Enter to leave it, Esc to stay |
|
||||||
|
|
||||||
|
|
|
||||||
65
src/tui.rs
65
src/tui.rs
|
|
@ -68,7 +68,7 @@ OnionWire keys\n\
|
||||||
1 / 2 / 3 focus roster / chat / composer\n\
|
1 / 2 / 3 focus roster / chat / composer\n\
|
||||||
j k or Up Down move or scroll focused pane\n\
|
j k or Up Down move or scroll focused pane\n\
|
||||||
g / G jump to top / bottom\n\
|
g / G jump to top / bottom\n\
|
||||||
Enter /wipe /wipe-all /profile /who /pay /tip\n\
|
Enter send chat to selected friend, or a /command\n\
|
||||||
Esc close overlay, go back, clear composer\n\
|
Esc close overlay, go back, clear composer\n\
|
||||||
F2 share invite\n\
|
F2 share invite\n\
|
||||||
F3 paste invite\n\
|
F3 paste invite\n\
|
||||||
|
|
@ -165,6 +165,28 @@ pub enum SlashCmd {
|
||||||
Restore { path: String },
|
Restore { path: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ComposerAction {
|
||||||
|
Cmd(SlashCmd),
|
||||||
|
Send(String),
|
||||||
|
UnknownSlash(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enter in the composer: slash command, chat send, or unknown `/cmd`.
|
||||||
|
pub fn composer_enter(raw: &str) -> Option<ComposerAction> {
|
||||||
|
let s = raw.trim();
|
||||||
|
if s.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if let Some(cmd) = parse_cmd(raw) {
|
||||||
|
return Some(ComposerAction::Cmd(cmd));
|
||||||
|
}
|
||||||
|
if s.starts_with('/') {
|
||||||
|
return Some(ComposerAction::UnknownSlash(s.to_string()));
|
||||||
|
}
|
||||||
|
Some(ComposerAction::Send(s.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ProfileDraft {
|
pub struct ProfileDraft {
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
|
|
@ -693,6 +715,7 @@ impl App {
|
||||||
let mut open_profile = false;
|
let mut open_profile = false;
|
||||||
let mut pay_cmd = None;
|
let mut pay_cmd = None;
|
||||||
let mut tip_cmd = None;
|
let mut tip_cmd = None;
|
||||||
|
let mut chat_send = None;
|
||||||
match &mut self.screen {
|
match &mut self.screen {
|
||||||
Screen::Main => match key.code {
|
Screen::Main => match key.code {
|
||||||
KeyCode::Tab => self.focus = self.focus.next(),
|
KeyCode::Tab => self.focus = self.focus.next(),
|
||||||
|
|
@ -718,8 +741,8 @@ impl App {
|
||||||
self.composer.pop();
|
self.composer.pop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Enter => match parse_cmd(&self.composer) {
|
KeyCode::Enter => match composer_enter(&self.composer) {
|
||||||
Some(SlashCmd::Wipe(kind)) => {
|
Some(ComposerAction::Cmd(SlashCmd::Wipe(kind))) => {
|
||||||
self.composer.clear();
|
self.composer.clear();
|
||||||
let prompt = match kind {
|
let prompt = match kind {
|
||||||
WipeKind::Messages => WipePrompt::messages(),
|
WipeKind::Messages => WipePrompt::messages(),
|
||||||
|
|
@ -727,23 +750,23 @@ impl App {
|
||||||
};
|
};
|
||||||
self.screen = Screen::Wipe { kind, prompt };
|
self.screen = Screen::Wipe { kind, prompt };
|
||||||
}
|
}
|
||||||
Some(SlashCmd::Profile) => {
|
Some(ComposerAction::Cmd(SlashCmd::Profile)) => {
|
||||||
self.composer.clear();
|
self.composer.clear();
|
||||||
open_profile = true;
|
open_profile = true;
|
||||||
}
|
}
|
||||||
Some(SlashCmd::Who) => {
|
Some(ComposerAction::Cmd(SlashCmd::Who)) => {
|
||||||
self.composer.clear();
|
self.composer.clear();
|
||||||
self.who_open = true;
|
self.who_open = true;
|
||||||
}
|
}
|
||||||
Some(SlashCmd::Pay { atomic, memo }) => {
|
Some(ComposerAction::Cmd(SlashCmd::Pay { atomic, memo })) => {
|
||||||
self.composer.clear();
|
self.composer.clear();
|
||||||
pay_cmd = Some((atomic, memo));
|
pay_cmd = Some((atomic, memo));
|
||||||
}
|
}
|
||||||
Some(SlashCmd::Tip { atomic, memo }) => {
|
Some(ComposerAction::Cmd(SlashCmd::Tip { atomic, memo })) => {
|
||||||
self.composer.clear();
|
self.composer.clear();
|
||||||
tip_cmd = Some((atomic, memo));
|
tip_cmd = Some((atomic, memo));
|
||||||
}
|
}
|
||||||
Some(SlashCmd::Backup { path }) => {
|
Some(ComposerAction::Cmd(SlashCmd::Backup { path })) => {
|
||||||
self.composer.clear();
|
self.composer.clear();
|
||||||
self.screen = Screen::ConfirmKeys {
|
self.screen = Screen::ConfirmKeys {
|
||||||
kind: BackupKind::Backup,
|
kind: BackupKind::Backup,
|
||||||
|
|
@ -751,7 +774,7 @@ impl App {
|
||||||
prompt: BackupPrompt::backup(),
|
prompt: BackupPrompt::backup(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Some(SlashCmd::Restore { path }) => {
|
Some(ComposerAction::Cmd(SlashCmd::Restore { path })) => {
|
||||||
self.composer.clear();
|
self.composer.clear();
|
||||||
self.screen = Screen::ConfirmKeys {
|
self.screen = Screen::ConfirmKeys {
|
||||||
kind: BackupKind::Restore,
|
kind: BackupKind::Restore,
|
||||||
|
|
@ -759,6 +782,14 @@ impl App {
|
||||||
prompt: BackupPrompt::restore(),
|
prompt: BackupPrompt::restore(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
Some(ComposerAction::Send(text)) => {
|
||||||
|
self.composer.clear();
|
||||||
|
chat_send = Some(text);
|
||||||
|
}
|
||||||
|
Some(ComposerAction::UnknownSlash(cmd)) => {
|
||||||
|
self.composer.clear();
|
||||||
|
self.status_note = Some(format!("unknown command {cmd}"));
|
||||||
|
}
|
||||||
None => {}
|
None => {}
|
||||||
},
|
},
|
||||||
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
|
@ -912,6 +943,9 @@ impl App {
|
||||||
if let Some((atomic, memo)) = tip_cmd {
|
if let Some((atomic, memo)) = tip_cmd {
|
||||||
self.send_tip(&atomic, &memo)?;
|
self.send_tip(&atomic, &memo)?;
|
||||||
}
|
}
|
||||||
|
if let Some(text) = chat_send {
|
||||||
|
self.send_chat(&text)?;
|
||||||
|
}
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1163,6 +1197,19 @@ impl App {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn send_chat(&mut self, text: &str) -> Result<(), String> {
|
||||||
|
let Some(friend) = self.friends.get(self.selected) else {
|
||||||
|
self.status_note = Some("no friend selected".into());
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let pk = friend.pubkey.clone();
|
||||||
|
match self.rt.block_on(self.node.send(&pk, text.as_bytes())) {
|
||||||
|
Ok(()) => self.status_note = Some("sent".into()),
|
||||||
|
Err(e) => self.status_note = Some(format!("send failed: {e}")),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn who_body(&self) -> String {
|
fn who_body(&self) -> String {
|
||||||
let Some(friend) = self.friends.get(self.selected) else {
|
let Some(friend) = self.friends.get(self.selected) else {
|
||||||
return "no friend selected\n\nEsc closes".into();
|
return "no friend selected\n\nEsc closes".into();
|
||||||
|
|
|
||||||
45
tests/send.rs
Normal file
45
tests/send.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
//! Composer Enter must send chat to the selected friend, not only slash commands.
|
||||||
|
|
||||||
|
use onionwire::tui::{composer_enter, ComposerAction, SlashCmd, WipeKind};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_plain_text_is_send() {
|
||||||
|
assert_eq!(
|
||||||
|
composer_enter("hello wire"),
|
||||||
|
Some(ComposerAction::Send("hello wire".into()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_trims_but_sends() {
|
||||||
|
assert_eq!(
|
||||||
|
composer_enter(" hi there "),
|
||||||
|
Some(ComposerAction::Send("hi there".into()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_empty_does_nothing() {
|
||||||
|
assert_eq!(composer_enter(""), None);
|
||||||
|
assert_eq!(composer_enter(" "), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_slash_cmds_are_not_chat() {
|
||||||
|
assert_eq!(
|
||||||
|
composer_enter("/wipe"),
|
||||||
|
Some(ComposerAction::Cmd(SlashCmd::Wipe(WipeKind::Messages)))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
composer_enter("/who"),
|
||||||
|
Some(ComposerAction::Cmd(SlashCmd::Who))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_unknown_slash_is_not_chat() {
|
||||||
|
assert_eq!(
|
||||||
|
composer_enter("/nope"),
|
||||||
|
Some(ComposerAction::UnknownSlash("/nope".into()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -40,8 +40,8 @@ fn help_overlay_lists_core_bindings() {
|
||||||
let help = help_overlay_text();
|
let help = help_overlay_text();
|
||||||
assert!(!help.is_empty());
|
assert!(!help.is_empty());
|
||||||
for needle in [
|
for needle in [
|
||||||
"Tab", "F2", "F3", "F4", "F5", "/profile", "/pay", "/tip", "/backup", "/restore", "Ctrl-Q",
|
"Tab", "F2", "F3", "F4", "F5", "send chat", "/profile", "/pay", "/tip", "/backup",
|
||||||
"?",
|
"/restore", "Ctrl-Q", "?",
|
||||||
] {
|
] {
|
||||||
assert!(help.contains(needle), "help overlay missing {needle:?}");
|
assert!(help.contains(needle), "help overlay missing {needle:?}");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue