feat(tui): fail-closed /file transfer (1 MiB) #16
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.
|
||||
|
||||
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
|
||||
|
||||
|
|
@ -131,7 +131,7 @@ Focus starts on the composer so typing works immediately. `Tab` cycles panes; `j
|
|||
| `F3` | Paste a friend’s invite |
|
||||
| `F4` | Rotate **onion** (locator only) |
|
||||
| `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 |
|
||||
| `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\
|
||||
j k or Up Down move or scroll focused pane\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\
|
||||
F2 share invite\n\
|
||||
F3 paste invite\n\
|
||||
|
|
@ -165,6 +165,28 @@ pub enum SlashCmd {
|
|||
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)]
|
||||
pub struct ProfileDraft {
|
||||
pub display_name: String,
|
||||
|
|
@ -693,6 +715,7 @@ impl App {
|
|||
let mut open_profile = false;
|
||||
let mut pay_cmd = None;
|
||||
let mut tip_cmd = None;
|
||||
let mut chat_send = None;
|
||||
match &mut self.screen {
|
||||
Screen::Main => match key.code {
|
||||
KeyCode::Tab => self.focus = self.focus.next(),
|
||||
|
|
@ -718,8 +741,8 @@ impl App {
|
|||
self.composer.pop();
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => match parse_cmd(&self.composer) {
|
||||
Some(SlashCmd::Wipe(kind)) => {
|
||||
KeyCode::Enter => match composer_enter(&self.composer) {
|
||||
Some(ComposerAction::Cmd(SlashCmd::Wipe(kind))) => {
|
||||
self.composer.clear();
|
||||
let prompt = match kind {
|
||||
WipeKind::Messages => WipePrompt::messages(),
|
||||
|
|
@ -727,23 +750,23 @@ impl App {
|
|||
};
|
||||
self.screen = Screen::Wipe { kind, prompt };
|
||||
}
|
||||
Some(SlashCmd::Profile) => {
|
||||
Some(ComposerAction::Cmd(SlashCmd::Profile)) => {
|
||||
self.composer.clear();
|
||||
open_profile = true;
|
||||
}
|
||||
Some(SlashCmd::Who) => {
|
||||
Some(ComposerAction::Cmd(SlashCmd::Who)) => {
|
||||
self.composer.clear();
|
||||
self.who_open = true;
|
||||
}
|
||||
Some(SlashCmd::Pay { atomic, memo }) => {
|
||||
Some(ComposerAction::Cmd(SlashCmd::Pay { atomic, memo })) => {
|
||||
self.composer.clear();
|
||||
pay_cmd = Some((atomic, memo));
|
||||
}
|
||||
Some(SlashCmd::Tip { atomic, memo }) => {
|
||||
Some(ComposerAction::Cmd(SlashCmd::Tip { atomic, memo })) => {
|
||||
self.composer.clear();
|
||||
tip_cmd = Some((atomic, memo));
|
||||
}
|
||||
Some(SlashCmd::Backup { path }) => {
|
||||
Some(ComposerAction::Cmd(SlashCmd::Backup { path })) => {
|
||||
self.composer.clear();
|
||||
self.screen = Screen::ConfirmKeys {
|
||||
kind: BackupKind::Backup,
|
||||
|
|
@ -751,7 +774,7 @@ impl App {
|
|||
prompt: BackupPrompt::backup(),
|
||||
};
|
||||
}
|
||||
Some(SlashCmd::Restore { path }) => {
|
||||
Some(ComposerAction::Cmd(SlashCmd::Restore { path })) => {
|
||||
self.composer.clear();
|
||||
self.screen = Screen::ConfirmKeys {
|
||||
kind: BackupKind::Restore,
|
||||
|
|
@ -759,6 +782,14 @@ impl App {
|
|||
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 => {}
|
||||
},
|
||||
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
|
|
@ -912,6 +943,9 @@ impl App {
|
|||
if let Some((atomic, memo)) = tip_cmd {
|
||||
self.send_tip(&atomic, &memo)?;
|
||||
}
|
||||
if let Some(text) = chat_send {
|
||||
self.send_chat(&text)?;
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
|
|
@ -1163,6 +1197,19 @@ impl App {
|
|||
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 {
|
||||
let Some(friend) = self.friends.get(self.selected) else {
|
||||
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();
|
||||
assert!(!help.is_empty());
|
||||
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:?}");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue