2026-09-10 01:54:19 -04:00
|
|
|
use std::io;
|
2026-09-10 02:43:45 -04:00
|
|
|
use std::sync::Arc;
|
2026-09-10 01:54:19 -04:00
|
|
|
|
|
|
|
|
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
|
|
|
|
use ratatui::layout::{Constraint, Layout};
|
2026-09-10 02:07:40 -04:00
|
|
|
use ratatui::style::{Color, Style};
|
2026-09-10 01:54:19 -04:00
|
|
|
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
|
|
|
|
|
use ratatui::{DefaultTerminal, Frame};
|
|
|
|
|
|
2026-09-10 02:43:45 -04:00
|
|
|
use crate::node::Node;
|
2026-09-10 01:54:19 -04:00
|
|
|
use crate::qr::{self, QrPayload};
|
2026-09-10 02:43:45 -04:00
|
|
|
use crate::store::Friend;
|
2026-09-10 01:54:19 -04:00
|
|
|
|
2026-09-10 02:07:40 -04:00
|
|
|
pub fn fingerprint_mismatch_banner() -> &'static str {
|
|
|
|
|
"fingerprint mismatch"
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 02:43:45 -04:00
|
|
|
pub fn rotate_screen_text() -> &'static str {
|
|
|
|
|
"Rotate onion address?\n\
|
|
|
|
|
Your identity key stays the same.\n\
|
|
|
|
|
Online friends get a signed location update.\n\
|
|
|
|
|
Offline friends CANNOT find you until they rescan your QR.\n\
|
|
|
|
|
Type ROTATE to confirm Esc to cancel"
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 02:49:42 -04:00
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
pub enum WipeKind {
|
|
|
|
|
Messages,
|
|
|
|
|
All,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
pub enum WipeDecision {
|
|
|
|
|
Pending,
|
|
|
|
|
Confirm,
|
|
|
|
|
Cancel,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
pub enum AppExit {
|
|
|
|
|
Quit,
|
|
|
|
|
WipeAll,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn parse_slash(raw: &str) -> Option<WipeKind> {
|
|
|
|
|
match raw.trim() {
|
|
|
|
|
"/wipe" => Some(WipeKind::Messages),
|
|
|
|
|
"/wipe-all" => Some(WipeKind::All),
|
|
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn wipe_screen_text(kind: WipeKind) -> &'static str {
|
|
|
|
|
match kind {
|
|
|
|
|
WipeKind::Messages => {
|
|
|
|
|
"Wipe message log?\n\
|
|
|
|
|
Identity key and friends stay.\n\
|
|
|
|
|
Type WIPE to confirm Esc to cancel"
|
|
|
|
|
}
|
|
|
|
|
WipeKind::All => {
|
|
|
|
|
"Wipe ALL local data?\n\
|
|
|
|
|
This deletes your identity key. You become a new person.\n\
|
|
|
|
|
Type WIPEALL to confirm Esc to cancel"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
pub struct WipePrompt {
|
|
|
|
|
expected: &'static str,
|
|
|
|
|
buf: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl WipePrompt {
|
|
|
|
|
pub fn messages() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
expected: "WIPE",
|
|
|
|
|
buf: String::new(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn all() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
expected: "WIPEALL",
|
|
|
|
|
buf: String::new(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn on_esc(&self) -> WipeDecision {
|
|
|
|
|
WipeDecision::Cancel
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn on_char(&mut self, c: char) -> WipeDecision {
|
|
|
|
|
if c == '\n' {
|
|
|
|
|
return WipeDecision::Pending;
|
|
|
|
|
}
|
|
|
|
|
if c == '\u{8}' {
|
|
|
|
|
self.buf.pop();
|
|
|
|
|
return WipeDecision::Pending;
|
|
|
|
|
}
|
|
|
|
|
if !c.is_ascii_alphabetic() {
|
|
|
|
|
return WipeDecision::Pending;
|
|
|
|
|
}
|
|
|
|
|
self.buf.push(c);
|
|
|
|
|
if self.buf == self.expected {
|
|
|
|
|
WipeDecision::Confirm
|
|
|
|
|
} else {
|
|
|
|
|
WipeDecision::Pending
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn typed(&self) -> &str {
|
|
|
|
|
&self.buf
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 02:43:45 -04:00
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
pub enum RotateDecision {
|
|
|
|
|
Pending,
|
|
|
|
|
Confirm,
|
|
|
|
|
Cancel,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Default)]
|
|
|
|
|
pub struct RotatePrompt {
|
|
|
|
|
buf: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl RotatePrompt {
|
|
|
|
|
pub fn new() -> Self {
|
|
|
|
|
Self::default()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn on_esc(&self) -> RotateDecision {
|
|
|
|
|
RotateDecision::Cancel
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn on_char(&mut self, c: char) -> RotateDecision {
|
|
|
|
|
if c == '\n' {
|
|
|
|
|
return RotateDecision::Pending;
|
|
|
|
|
}
|
|
|
|
|
if c == '\u{8}' {
|
|
|
|
|
self.buf.pop();
|
|
|
|
|
return RotateDecision::Pending;
|
|
|
|
|
}
|
|
|
|
|
if !c.is_ascii_alphabetic() {
|
|
|
|
|
return RotateDecision::Pending;
|
|
|
|
|
}
|
|
|
|
|
self.buf.push(c);
|
|
|
|
|
if self.buf == "ROTATE" {
|
|
|
|
|
RotateDecision::Confirm
|
|
|
|
|
} else {
|
|
|
|
|
RotateDecision::Pending
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn typed(&self) -> &str {
|
|
|
|
|
&self.buf
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 02:49:42 -04:00
|
|
|
pub fn run(node: Arc<Node>, rt: tokio::runtime::Handle) -> Result<AppExit, String> {
|
2026-09-10 02:43:45 -04:00
|
|
|
let mut app = App::new(node, rt)?;
|
2026-09-10 01:54:19 -04:00
|
|
|
let mut terminal = ratatui::init();
|
|
|
|
|
let result = app.run(&mut terminal);
|
|
|
|
|
ratatui::restore();
|
|
|
|
|
result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct App {
|
2026-09-10 02:43:45 -04:00
|
|
|
node: Arc<Node>,
|
|
|
|
|
rt: tokio::runtime::Handle,
|
2026-09-10 01:54:19 -04:00
|
|
|
screen: Screen,
|
|
|
|
|
friends: Vec<Friend>,
|
|
|
|
|
selected: usize,
|
|
|
|
|
me_fp: String,
|
|
|
|
|
me_onion: String,
|
2026-09-10 02:07:40 -04:00
|
|
|
alert: Option<String>,
|
2026-09-10 02:43:45 -04:00
|
|
|
status_note: Option<String>,
|
2026-09-10 02:49:42 -04:00
|
|
|
composer: String,
|
2026-09-10 01:54:19 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
enum Screen {
|
|
|
|
|
Main,
|
|
|
|
|
Share { art: String, payload: String },
|
|
|
|
|
Paste { buf: String, err: Option<String> },
|
|
|
|
|
Approve { payload: QrPayload },
|
2026-09-10 02:43:45 -04:00
|
|
|
Rotate { prompt: RotatePrompt },
|
2026-09-10 02:49:42 -04:00
|
|
|
Wipe { kind: WipeKind, prompt: WipePrompt },
|
2026-09-10 01:54:19 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl App {
|
2026-09-10 02:43:45 -04:00
|
|
|
fn new(node: Arc<Node>, rt: tokio::runtime::Handle) -> Result<Self, String> {
|
|
|
|
|
let friends = node.list_friends()?;
|
2026-09-10 01:54:19 -04:00
|
|
|
Ok(Self {
|
2026-09-10 02:43:45 -04:00
|
|
|
me_fp: to_hex(&node.identity_pk()),
|
|
|
|
|
me_onion: node.onion(),
|
|
|
|
|
node,
|
|
|
|
|
rt,
|
2026-09-10 01:54:19 -04:00
|
|
|
screen: Screen::Main,
|
|
|
|
|
friends,
|
|
|
|
|
selected: 0,
|
2026-09-10 02:07:40 -04:00
|
|
|
alert: None,
|
2026-09-10 02:43:45 -04:00
|
|
|
status_note: None,
|
2026-09-10 02:49:42 -04:00
|
|
|
composer: String::new(),
|
2026-09-10 01:54:19 -04:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 02:49:42 -04:00
|
|
|
fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<AppExit, String> {
|
2026-09-10 01:54:19 -04:00
|
|
|
loop {
|
|
|
|
|
terminal.draw(|f| self.draw(f)).map_err(|e| e.to_string())?;
|
|
|
|
|
let Event::Key(key) = event::read().map_err(io_err)? else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
if key.kind != KeyEventKind::Press {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if key.code == KeyCode::Char('q') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
2026-09-10 02:49:42 -04:00
|
|
|
return Ok(AppExit::Quit);
|
|
|
|
|
}
|
|
|
|
|
if let Some(exit) = self.handle_key(key)? {
|
|
|
|
|
return Ok(exit);
|
2026-09-10 01:54:19 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 02:49:42 -04:00
|
|
|
fn handle_key(&mut self, key: KeyEvent) -> Result<Option<AppExit>, String> {
|
|
|
|
|
let mut wipe_confirm = None;
|
2026-09-10 01:54:19 -04:00
|
|
|
match &mut self.screen {
|
|
|
|
|
Screen::Main => match key.code {
|
|
|
|
|
KeyCode::Up => {
|
|
|
|
|
self.selected = self.selected.saturating_sub(1);
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Down => {
|
|
|
|
|
if !self.friends.is_empty() {
|
|
|
|
|
self.selected = (self.selected + 1).min(self.friends.len() - 1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
KeyCode::F(2) => self.open_share()?,
|
|
|
|
|
KeyCode::F(3) => {
|
|
|
|
|
self.screen = Screen::Paste {
|
|
|
|
|
buf: String::new(),
|
|
|
|
|
err: None,
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-09-10 02:43:45 -04:00
|
|
|
KeyCode::F(4) => {
|
|
|
|
|
self.screen = Screen::Rotate {
|
|
|
|
|
prompt: RotatePrompt::new(),
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-09-10 02:49:42 -04:00
|
|
|
KeyCode::Esc => self.composer.clear(),
|
|
|
|
|
KeyCode::Backspace => {
|
|
|
|
|
self.composer.pop();
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Enter => {
|
|
|
|
|
if let Some(kind) = parse_slash(&self.composer) {
|
|
|
|
|
self.composer.clear();
|
|
|
|
|
let prompt = match kind {
|
|
|
|
|
WipeKind::Messages => WipePrompt::messages(),
|
|
|
|
|
WipeKind::All => WipePrompt::all(),
|
|
|
|
|
};
|
|
|
|
|
self.screen = Screen::Wipe { kind, prompt };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
|
|
|
self.composer.push(c);
|
|
|
|
|
}
|
2026-09-10 01:54:19 -04:00
|
|
|
_ => {}
|
|
|
|
|
},
|
|
|
|
|
Screen::Share { .. } => {
|
|
|
|
|
if key.code == KeyCode::Esc {
|
|
|
|
|
self.screen = Screen::Main;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Screen::Paste { buf, err } => match key.code {
|
|
|
|
|
KeyCode::Esc => self.screen = Screen::Main,
|
|
|
|
|
KeyCode::Enter => {
|
|
|
|
|
let raw = buf.clone();
|
|
|
|
|
self.submit_paste(&raw)?;
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Backspace => {
|
|
|
|
|
buf.pop();
|
|
|
|
|
*err = None;
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
|
|
|
buf.push(c);
|
|
|
|
|
*err = None;
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
},
|
|
|
|
|
Screen::Approve { payload } => match key.code {
|
|
|
|
|
KeyCode::Char('y') | KeyCode::Enter => {
|
|
|
|
|
let p = payload.clone();
|
|
|
|
|
self.accept_friend(&p)?;
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Char('n') | KeyCode::Esc => self.screen = Screen::Main,
|
|
|
|
|
_ => {}
|
|
|
|
|
},
|
2026-09-10 02:43:45 -04:00
|
|
|
Screen::Rotate { prompt } => match key.code {
|
|
|
|
|
KeyCode::Esc => self.screen = Screen::Main,
|
|
|
|
|
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
|
|
|
if prompt.on_char(c) == RotateDecision::Confirm {
|
|
|
|
|
self.confirm_rotate()?;
|
|
|
|
|
}
|
2026-09-10 01:54:19 -04:00
|
|
|
}
|
2026-09-10 02:43:45 -04:00
|
|
|
KeyCode::Backspace => {
|
|
|
|
|
prompt.on_char('\u{8}');
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
},
|
2026-09-10 02:49:42 -04:00
|
|
|
Screen::Wipe { kind, prompt } => {
|
|
|
|
|
let kind = *kind;
|
|
|
|
|
match key.code {
|
|
|
|
|
KeyCode::Esc => self.screen = Screen::Main,
|
|
|
|
|
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
|
|
|
if prompt.on_char(c) == WipeDecision::Confirm {
|
|
|
|
|
wipe_confirm = Some(kind);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Backspace => {
|
|
|
|
|
prompt.on_char('\u{8}');
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if let Some(kind) = wipe_confirm {
|
|
|
|
|
return self.confirm_wipe(kind);
|
|
|
|
|
}
|
|
|
|
|
Ok(None)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn confirm_wipe(&mut self, kind: WipeKind) -> Result<Option<AppExit>, String> {
|
|
|
|
|
match kind {
|
|
|
|
|
WipeKind::Messages => {
|
|
|
|
|
self.node.wipe_messages()?;
|
|
|
|
|
self.status_note = Some("wiped messages".into());
|
|
|
|
|
self.screen = Screen::Main;
|
|
|
|
|
Ok(None)
|
|
|
|
|
}
|
|
|
|
|
WipeKind::All => Ok(Some(AppExit::WipeAll)),
|
2026-09-10 01:54:19 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 02:43:45 -04:00
|
|
|
fn confirm_rotate(&mut self) -> Result<(), String> {
|
|
|
|
|
let node = Arc::clone(&self.node);
|
|
|
|
|
match self.rt.block_on(node.rotate()) {
|
|
|
|
|
Ok(outcome) => {
|
|
|
|
|
self.me_onion = node.onion();
|
|
|
|
|
self.status_note = Some(format!(
|
|
|
|
|
"rotated · notified {}/{} friends",
|
|
|
|
|
outcome.notified, outcome.friends
|
|
|
|
|
));
|
|
|
|
|
self.open_share()
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
self.alert = Some(e);
|
|
|
|
|
self.screen = Screen::Main;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 01:54:19 -04:00
|
|
|
fn open_share(&mut self) -> Result<(), String> {
|
2026-09-10 02:43:45 -04:00
|
|
|
let payload = self.node.qr_payload()?;
|
2026-09-10 01:54:19 -04:00
|
|
|
let art = qr::render_unicode(&payload).map_err(|e| e.to_string())?;
|
|
|
|
|
self.screen = Screen::Share { art, payload };
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn submit_paste(&mut self, raw: &str) -> Result<(), String> {
|
|
|
|
|
match qr::decode(raw.trim()) {
|
|
|
|
|
Err(e) => {
|
|
|
|
|
self.screen = Screen::Paste {
|
|
|
|
|
buf: raw.to_string(),
|
|
|
|
|
err: Some(e.to_string()),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
Ok(payload) => {
|
2026-09-10 02:43:45 -04:00
|
|
|
let known = self.node.get_friend(&payload.pubkey)?.is_some();
|
2026-09-10 01:54:19 -04:00
|
|
|
if known {
|
|
|
|
|
self.accept_friend(&payload)?;
|
|
|
|
|
} else {
|
|
|
|
|
self.screen = Screen::Approve { payload };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn accept_friend(&mut self, payload: &QrPayload) -> Result<(), String> {
|
2026-09-10 02:43:45 -04:00
|
|
|
self.node.add_friend_payload(payload)?;
|
2026-09-10 01:54:19 -04:00
|
|
|
self.reload_friends()?;
|
|
|
|
|
self.screen = Screen::Main;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn reload_friends(&mut self) -> Result<(), String> {
|
2026-09-10 02:43:45 -04:00
|
|
|
self.friends = self.node.list_friends()?;
|
2026-09-10 01:54:19 -04:00
|
|
|
if self.friends.is_empty() {
|
|
|
|
|
self.selected = 0;
|
|
|
|
|
} else {
|
|
|
|
|
self.selected = self.selected.min(self.friends.len() - 1);
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn draw(&self, f: &mut Frame) {
|
|
|
|
|
match &self.screen {
|
|
|
|
|
Screen::Main => self.draw_main(f),
|
|
|
|
|
Screen::Share { art, payload } => draw_share(f, art, payload),
|
|
|
|
|
Screen::Paste { buf, err } => draw_paste(f, buf, err.as_deref()),
|
|
|
|
|
Screen::Approve { payload } => draw_approve(f, payload),
|
2026-09-10 02:43:45 -04:00
|
|
|
Screen::Rotate { prompt } => draw_rotate(f, prompt.typed()),
|
2026-09-10 02:49:42 -04:00
|
|
|
Screen::Wipe { kind, prompt } => draw_wipe(f, *kind, prompt.typed()),
|
2026-09-10 01:54:19 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn draw_main(&self, f: &mut Frame) {
|
|
|
|
|
let area = f.area();
|
2026-09-10 02:49:42 -04:00
|
|
|
let cols = Layout::vertical([
|
|
|
|
|
Constraint::Min(1),
|
|
|
|
|
Constraint::Length(1),
|
|
|
|
|
Constraint::Length(1),
|
|
|
|
|
])
|
|
|
|
|
.split(area);
|
2026-09-10 02:07:40 -04:00
|
|
|
let body = if self.alert.is_some() {
|
|
|
|
|
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(cols[0])
|
|
|
|
|
} else {
|
|
|
|
|
Layout::vertical([Constraint::Length(0), Constraint::Min(1)]).split(cols[0])
|
|
|
|
|
};
|
|
|
|
|
if let Some(msg) = &self.alert {
|
|
|
|
|
f.render_widget(
|
|
|
|
|
Paragraph::new(msg.as_str()).style(Style::default().fg(Color::Red)),
|
|
|
|
|
body[0],
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-09-10 01:54:19 -04:00
|
|
|
let panes =
|
2026-09-10 02:07:40 -04:00
|
|
|
Layout::horizontal([Constraint::Percentage(36), Constraint::Min(10)]).split(body[1]);
|
2026-09-10 01:54:19 -04:00
|
|
|
|
|
|
|
|
let items: Vec<ListItem> = self
|
|
|
|
|
.friends
|
|
|
|
|
.iter()
|
|
|
|
|
.enumerate()
|
|
|
|
|
.map(|(i, friend)| {
|
|
|
|
|
let name = friend.petname.as_deref().unwrap_or(&friend.fingerprint);
|
|
|
|
|
let fp = truncate_fp(&friend.fingerprint);
|
|
|
|
|
let stale = if friend.last_connect_ok { "" } else { " stale" };
|
|
|
|
|
let mark = if i == self.selected { ">" } else { " " };
|
|
|
|
|
ListItem::new(format!("{mark} {name} {fp}{stale}"))
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
let roster = List::new(items).block(Block::default().borders(Borders::ALL).title("roster"));
|
|
|
|
|
f.render_widget(roster, panes[0]);
|
|
|
|
|
|
|
|
|
|
let chat_text = if let Some(friend) = self.friends.get(self.selected) {
|
|
|
|
|
let name = friend.petname.as_deref().unwrap_or(&friend.fingerprint);
|
|
|
|
|
format!("{name}\n\nchat arrives in M3")
|
|
|
|
|
} else {
|
|
|
|
|
"no friends yet\nF3 paste a QR to add one".into()
|
|
|
|
|
};
|
|
|
|
|
let chat = Paragraph::new(chat_text)
|
|
|
|
|
.wrap(Wrap { trim: false })
|
|
|
|
|
.block(Block::default().borders(Borders::ALL).title("chat"));
|
|
|
|
|
f.render_widget(chat, panes[1]);
|
|
|
|
|
|
|
|
|
|
let onion = if self.me_onion.is_empty() {
|
|
|
|
|
"unset"
|
|
|
|
|
} else {
|
|
|
|
|
self.me_onion.as_str()
|
|
|
|
|
};
|
2026-09-10 02:43:45 -04:00
|
|
|
let note = self
|
|
|
|
|
.status_note
|
|
|
|
|
.as_deref()
|
|
|
|
|
.map(|s| format!("{s} | "))
|
|
|
|
|
.unwrap_or_default();
|
2026-09-10 01:54:19 -04:00
|
|
|
let status = format!(
|
2026-09-10 02:43:45 -04:00
|
|
|
"{note}TOR: up | me: {} | onion: {} F2 share F3 paste F4 rotate Ctrl-Q quit",
|
2026-09-10 01:54:19 -04:00
|
|
|
truncate_fp(&self.me_fp),
|
|
|
|
|
onion
|
|
|
|
|
);
|
2026-09-10 02:49:42 -04:00
|
|
|
let cmd = if self.composer.is_empty() {
|
|
|
|
|
"/wipe /wipe-all".to_string()
|
|
|
|
|
} else {
|
|
|
|
|
self.composer.clone()
|
|
|
|
|
};
|
|
|
|
|
f.render_widget(Paragraph::new(cmd), cols[1]);
|
|
|
|
|
f.render_widget(Paragraph::new(status), cols[2]);
|
2026-09-10 01:54:19 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn draw_share(f: &mut Frame, art: &str, payload: &str) {
|
|
|
|
|
let body = format!("{art}\n{payload}\n\nEsc back");
|
|
|
|
|
f.render_widget(
|
|
|
|
|
Paragraph::new(body)
|
|
|
|
|
.wrap(Wrap { trim: false })
|
|
|
|
|
.block(Block::default().borders(Borders::ALL).title("F2 share")),
|
|
|
|
|
f.area(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn draw_paste(f: &mut Frame, buf: &str, err: Option<&str>) {
|
|
|
|
|
let hint = err.unwrap_or("paste onionwire:v1 payload, Enter to submit");
|
|
|
|
|
let body = format!("{hint}\n\n{buf}");
|
|
|
|
|
f.render_widget(
|
|
|
|
|
Paragraph::new(body)
|
|
|
|
|
.wrap(Wrap { trim: false })
|
|
|
|
|
.block(Block::default().borders(Borders::ALL).title("F3 paste QR")),
|
|
|
|
|
f.area(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn draw_approve(f: &mut Frame, payload: &QrPayload) {
|
|
|
|
|
let fp = to_hex(&payload.pubkey);
|
|
|
|
|
let body = format!(
|
|
|
|
|
"Unknown key. Add this friend?\n\nfp {}\nonion {}\n\ny accept n / Esc reject",
|
|
|
|
|
truncate_fp(&fp),
|
|
|
|
|
payload.onion
|
|
|
|
|
);
|
|
|
|
|
f.render_widget(
|
|
|
|
|
Paragraph::new(body).block(Block::default().borders(Borders::ALL).title("approve")),
|
|
|
|
|
f.area(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 02:43:45 -04:00
|
|
|
fn draw_rotate(f: &mut Frame, typed: &str) {
|
|
|
|
|
let body = format!("{}\n\n{typed}", rotate_screen_text());
|
2026-09-10 01:54:19 -04:00
|
|
|
f.render_widget(
|
|
|
|
|
Paragraph::new(body)
|
|
|
|
|
.wrap(Wrap { trim: false })
|
|
|
|
|
.block(Block::default().borders(Borders::ALL).title("F4 rotate")),
|
|
|
|
|
f.area(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 02:49:42 -04:00
|
|
|
fn draw_wipe(f: &mut Frame, kind: WipeKind, typed: &str) {
|
|
|
|
|
let title = match kind {
|
|
|
|
|
WipeKind::Messages => "/wipe",
|
|
|
|
|
WipeKind::All => "/wipe-all",
|
|
|
|
|
};
|
|
|
|
|
let body = format!("{}\n\n{typed}", wipe_screen_text(kind));
|
|
|
|
|
f.render_widget(
|
|
|
|
|
Paragraph::new(body)
|
|
|
|
|
.wrap(Wrap { trim: false })
|
|
|
|
|
.block(Block::default().borders(Borders::ALL).title(title)),
|
|
|
|
|
f.area(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 01:54:19 -04:00
|
|
|
fn truncate_fp(fp: &str) -> String {
|
|
|
|
|
let s: String = fp.chars().take(8).collect();
|
|
|
|
|
format!("{s}…")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn to_hex(bytes: &[u8]) -> String {
|
|
|
|
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn io_err(e: io::Error) -> String {
|
|
|
|
|
e.to_string()
|
|
|
|
|
}
|