use std::cell::Cell;
use std::collections::{HashMap, HashSet, VecDeque};
use std::io;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use anyhow::Result;
use crossterm::{
event::{
self, poll, DisableBracketedPaste, DisableFocusChange, DisableMouseCapture,
EnableBracketedPaste, EnableFocusChange, EnableMouseCapture, Event, KeyCode, KeyEvent,
KeyModifiers, MouseButton, MouseEventKind,
},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::prelude::*;
use ratatui::Terminal;
use base64::Engine;
use huddle_core::app::events::{AppEvent, DiscoveredRoom};
use huddle_core::app::{AppHandle, KnownPeerStatus};
use huddle_core::network::NetworkMode;
use huddle_core::storage::repo::{StoredAttachment, StoredRoomMessage};
use libp2p::PeerId;
use crate::input::{self, Action};
const STATUS_TTL: Duration = Duration::from_secs(6);
pub const STATUS_HISTORY_CAP: usize = 100;
pub const PENDING_MODAL_CAP: usize = 16;
pub struct OnboardingPage {
pub title: &'static str,
pub body: &'static [&'static str],
pub min_version: &'static str,
}
pub const ONBOARDING_PAGES: &[OnboardingPage] = &[
OnboardingPage {
title: "huddle is not iMessage",
body: &[
"your messages are end-to-end encrypted, and everything",
"travels over a Tor onion relay that only ever sees",
"ciphertext — never your keys, your IP, or who you are.",
"there's no account, just an identity key on this device.",
"",
"rooms outlive whoever made them: anyone with the room",
"passphrase can join, post, and rotate the key.",
"",
"press Enter, Tab, Space, or → to continue.",
],
min_version: "0.0.0",
},
OnboardingPage {
title: "passphrase ≠ password",
body: &[
"the master passphrase encrypts your LOCAL database (rooms,",
"messages, members, Megolm sessions, attachments).",
"room passphrases are the access keys to encrypted rooms.",
"neither is recoverable — there's no reset, by design.",
"",
"for sharing access without leaking your passphrase, use",
" ^J generate a 10-min single-use join code",
" ^V→s SAS-verify a member's fingerprint",
" ^I produce an invite link (passphrase still OOB)",
],
min_version: "0.0.0",
},
OnboardingPage {
title: "what's new in 0.5",
body: &[
" a add friend by HD ID or username — races LAN / IP / relay",
" ,→u set / clear your username (signed broadcast)",
" Alt+Shift+1 delete account + wipe data dir (go dark)",
" ✓ green tag next to SAS-verified peers in chat",
" HD- branded ID, shown alongside username everywhere",
],
min_version: "0.5.0",
},
OnboardingPage {
title: "what's new in 0.6 — UX overhaul",
body: &[
" Ctrl+P command palette — fuzzy-search every action",
" Ctrl+H notification history (last 100 events)",
" Shift+? reopen this card · ? help (scroll j/k)",
" R (lobby) mark every room read",
"",
"Also: version + clock in the header, per-tab unread",
"counts, day separators in chat, and a `huddle doctor`",
"CLI subcommand for bug reports.",
],
min_version: "0.6.0",
},
OnboardingPage {
title: "what's new in 0.7 — TUI 2.0",
body: &[
"huddle 0.7 rewrote the TUI around a sidebar:",
" Profile · Direct messages · Group rooms ·",
" People · Activity · Settings",
"",
"Keys: m DM g group room p People",
" , Settings Tab/Shift+Tab switch section",
" Space / ← / → expand or collapse a section",
"",
"DMs and group chats are visually distinct; DMs stay 1-1.",
],
min_version: "0.7.0",
},
OnboardingPage {
title: "what's new in 0.7.1 — E2E DMs",
body: &[
"DMs are end-to-end encrypted on the room layer.",
"",
"Each DM derives a Megolm wrap key from an Ed25519→",
"X25519 ECDH between the two parties' identity keys,",
"bound to the room_id via HKDF-SHA256 — no shared",
"passphrase, no extra prompt. `m` starts a DM and it's",
"E2E from the first wrapped session key onward.",
],
min_version: "0.7.1",
},
OnboardingPage {
title: "what's new in 0.7.4 — desktop notifications",
body: &[
"You'll get a desktop notification when a message arrives",
"and the terminal isn't focused — switch apps or lock your",
"screen and you won't miss it. When huddle is focused,",
"nothing pops up; you're already looking at it.",
"",
"Reopening huddle rolls anything you missed in the first",
"few seconds into a single \"N new messages\" summary.",
"",
"Going dark is now Alt+Shift+1 — the extra modifier is",
"there so a stray keystroke can't wipe your account.",
],
min_version: "0.7.4",
},
OnboardingPage {
title: "what's new in 0.8 — Tor onion relay",
body: &[
"huddle now talks over a Tor onion relay by default —",
"no more flaky NAT hole-punching, and still fully",
"end-to-end encrypted (the relay only sees ciphertext).",
"",
" · the dot by your name: ● connected, ○ connecting",
" · needs Tor running locally (SOCKS5 127.0.0.1:9050)",
" · LAN/libp2p is now opt-in: --mode mdns | direct",
"",
"To get started, make a group room (g) and share the",
"invite (Shift+I) — your friend pastes it and you're in.",
],
min_version: "0.8.0",
},
];
pub fn pages_to_show(last_seen: Option<&str>, legacy_onboarding_seen: bool) -> Vec<usize> {
let baseline = match (last_seen, legacy_onboarding_seen) {
(Some(v), _) => v.to_string(),
(None, true) => "0.5.2".to_string(),
(None, false) => "0.0.0".to_string(),
};
ONBOARDING_PAGES
.iter()
.enumerate()
.filter(|(_, p)| semver_lt(&baseline, p.min_version))
.map(|(i, _)| i)
.collect()
}
fn semver_lt(a: &str, b: &str) -> bool {
parse_semver(a) < parse_semver(b)
}
fn parse_semver(s: &str) -> (u32, u32, u32) {
let mut it = s.split('.');
let major = it.next().unwrap_or("0").parse().unwrap_or(0);
let minor = it.next().unwrap_or("0").parse().unwrap_or(0);
let patch_raw = it.next().unwrap_or("0");
let patch_num: String = patch_raw.chars().take_while(|c| c.is_ascii_digit()).collect();
let patch = patch_num.parse().unwrap_or(0);
(major, minor, patch)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Pane {
Welcome,
Profile,
Dm(String),
Group(String),
People,
Activity,
Settings,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SidebarSection {
Profile,
Direct,
Group,
People,
Activity,
Settings,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SidebarItem {
Section(SidebarSection),
Profile,
DirectAddFriend,
Dm(String),
GroupNew,
Group(String),
GroupDiscover,
PeoplePendingBadge,
Person(String),
Activity,
Settings,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsTab {
Account,
Network,
Appearance,
Privacy,
}
impl Default for SettingsTab {
fn default() -> Self {
SettingsTab::Account
}
}
impl SettingsTab {
pub fn label(self) -> &'static str {
match self {
SettingsTab::Account => "Account",
SettingsTab::Network => "Network",
SettingsTab::Appearance => "Appearance",
SettingsTab::Privacy => "Privacy",
}
}
pub fn next(self) -> Self {
match self {
SettingsTab::Account => SettingsTab::Network,
SettingsTab::Network => SettingsTab::Appearance,
SettingsTab::Appearance => SettingsTab::Privacy,
SettingsTab::Privacy => SettingsTab::Account,
}
}
pub fn prev(self) -> Self {
match self {
SettingsTab::Account => SettingsTab::Privacy,
SettingsTab::Network => SettingsTab::Account,
SettingsTab::Appearance => SettingsTab::Network,
SettingsTab::Privacy => SettingsTab::Appearance,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SidebarFocus {
Sidebar,
Pane,
}
#[derive(Debug, Clone)]
pub struct SidebarState {
pub selection: SidebarItem,
pub expanded: HashSet<SidebarSection>,
pub focus: SidebarFocus,
}
impl Default for SidebarState {
fn default() -> Self {
let mut expanded = HashSet::new();
expanded.insert(SidebarSection::Profile);
expanded.insert(SidebarSection::Direct);
expanded.insert(SidebarSection::Group);
expanded.insert(SidebarSection::People);
Self {
selection: SidebarItem::Section(SidebarSection::Profile),
expanded,
focus: SidebarFocus::Sidebar,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PeopleFocus {
ContactRequests,
Pending,
Known,
Verified,
Blocked,
}
impl Default for PeopleFocus {
fn default() -> Self {
PeopleFocus::Known
}
}
#[derive(Debug, Clone)]
pub enum Modal {
None,
StartRoom(StartRoomState),
JoinRoom(JoinRoomState),
DialPeer(DialPeerState),
AttachPicker(AttachPickerState),
RotateRoom(RotateRoomState),
AcceptRotation(AcceptRotationState),
Verify(VerifyState),
Search(SearchState),
QrIdentity,
InboundDial(InboundDialState),
MemberAction(MemberActionState),
Sas(SasState),
EditUsername(EditUsernameState),
GoDark(GoDarkState),
AddFriend(AddFriendState),
ComposeDm(ComposeDmState),
ShowJoinCode(ShowJoinCodeState),
JoinWithCode(JoinWithCodeState),
ShowInvite(ShowInviteState),
PasteInvite(PasteInviteState),
ConfirmInvite(ConfirmInviteState),
Onboarding {
pages: Vec<usize>,
cursor: usize,
},
StatusHistory {
scroll: u16,
},
CommandPalette(CommandPaletteState),
UpdateCheckOptIn,
InvitePicker(InvitePickerState),
QuitConfirm,
ConfirmClearBlocked,
Help,
Error(String),
Info(String),
}
#[derive(Debug, Clone, Default)]
pub struct CommandPaletteState {
pub query: String,
pub selected: usize,
}
#[derive(Debug, Clone, Default)]
pub struct EditUsernameState {
pub input: String,
}
#[derive(Debug, Clone, Default)]
pub struct GoDarkState {
pub input: String,
pub requires_passphrase: bool,
pub last_error: Option<String>,
}
pub const GO_DARK_CONFIRM_PHRASE: &str = "DELETE EVERYTHING";
#[derive(Debug, Clone, Default)]
pub struct AddFriendState {
pub input: String,
}
#[derive(Debug, Clone, Default)]
pub struct ComposeDmState {
pub input: String,
}
#[derive(Debug, Clone)]
pub struct ShowJoinCodeState {
pub room_id: String,
pub room_name: String,
pub code: String,
}
#[derive(Debug, Clone)]
pub struct JoinWithCodeState {
pub room_id: String,
pub room_name: String,
pub code: String,
}
#[derive(Debug, Clone)]
pub struct ShowInviteState {
pub url: String,
pub includes_room: Option<String>,
}
#[derive(Debug, Clone)]
pub struct PasteInviteState {
pub url: String,
}
#[derive(Debug, Clone)]
pub struct ConfirmInviteState {
pub invite: huddle_core::invite::InviteLink,
}
#[derive(Debug, Clone)]
pub enum SasStage {
Waiting,
Comparing {
emoji_labels: String,
decimal: String,
our_matched: bool,
},
}
#[derive(Debug, Clone)]
pub struct SasState {
pub room_id: String,
pub partner_fingerprint: String,
pub tx_id: String,
pub stage: SasStage,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MemberActionKind {
Kick,
Grant,
}
#[derive(Debug, Clone)]
pub struct MemberActionState {
pub room_id: String,
pub kind: MemberActionKind,
pub members: Vec<(String, bool)>,
pub selected: usize,
}
#[derive(Debug, Clone)]
pub struct InboundDialState {
pub peer_id: PeerId,
pub fingerprint: String,
pub address: String,
pub opened_at: Instant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum InviteTier {
Verified,
DmPartner,
Known,
}
#[derive(Debug, Clone)]
pub struct InviteCandidate {
pub fingerprint: String,
pub username: Option<String>,
pub tier: InviteTier,
}
pub const INVITE_PICKER_SOFT_CAP: usize = 20;
#[derive(Debug, Clone, Default)]
pub struct InvitePickerState {
pub room_id: String,
pub room_name: String,
pub candidates: Vec<InviteCandidate>,
pub selected: HashSet<String>,
pub filter: String,
pub cursor: usize,
pub status_line: Option<String>,
}
#[derive(Debug, Clone)]
pub struct RotateRoomState {
pub room_id: String,
pub passphrase: String,
}
#[derive(Debug, Clone)]
pub struct AcceptRotationState {
pub room_id: String,
pub rotator_fingerprint: String,
pub new_salt: Vec<u8>,
pub passphrase: String,
}
#[derive(Debug, Clone)]
pub struct SearchState {
pub room_id: String,
pub query: String,
pub results: Vec<StoredRoomMessage>,
pub selected: usize,
pub searched: bool,
}
#[derive(Debug, Clone)]
pub struct VerifyState {
pub room_id: String,
pub our_fingerprint: String,
pub members: Vec<(String, bool)>,
pub selected: usize,
}
pub const ATTACH_VISIBLE_ROWS: usize = 14;
const ATTACH_MAX_CHILDREN: usize = 5000;
#[derive(Debug, Clone)]
pub struct TreeNode {
pub name: String,
pub path: std::path::PathBuf,
pub is_dir: bool,
pub expanded: bool,
pub loaded: bool,
pub load_error: Option<String>,
pub children: Vec<TreeNode>,
}
#[derive(Debug, Clone)]
pub struct FlatRow {
pub path: std::path::PathBuf,
pub name: String,
pub is_dir: bool,
pub expanded: bool,
pub depth: usize,
pub has_error: bool,
pub is_empty_dir: bool,
}
#[derive(Debug, Clone)]
pub struct AttachPickerState {
pub root: std::path::PathBuf,
pub roots: Vec<TreeNode>,
pub flat: Vec<FlatRow>,
pub selected: usize,
pub scroll: usize,
pub show_hidden: bool,
pub error: Option<String>,
}
impl AttachPickerState {
pub fn new() -> Self {
let start = dirs::download_dir()
.or_else(dirs::home_dir)
.unwrap_or_else(|| std::path::PathBuf::from("/"));
let mut s = Self {
root: start,
roots: Vec::new(),
flat: Vec::new(),
selected: 0,
scroll: 0,
show_hidden: false,
error: None,
};
s.load_root();
s
}
fn load_root(&mut self) {
match Self::read_children(&self.root, self.show_hidden) {
Ok(nodes) => {
self.roots = nodes;
self.error = None;
}
Err(e) => {
self.roots.clear();
self.error = Some(format!("cannot read {}: {}", self.root.display(), e));
}
}
self.selected = 0;
self.scroll = 0;
self.flat.clear();
self.rebuild_flat();
}
fn read_children(
dir: &std::path::Path,
show_hidden: bool,
) -> std::io::Result<Vec<TreeNode>> {
let rd = std::fs::read_dir(dir)?;
let mut tmp: Vec<TreeNode> = Vec::new();
for entry in rd.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if !show_hidden && name.starts_with('.') {
continue;
}
let path = entry.path();
let is_dir = std::fs::metadata(&path).map(|m| m.is_dir()).unwrap_or(false);
tmp.push(TreeNode {
name,
path,
is_dir,
expanded: false,
loaded: false,
load_error: None,
children: Vec::new(),
});
if tmp.len() >= ATTACH_MAX_CHILDREN {
break;
}
}
tmp.sort_by(|a, b| match (a.is_dir, b.is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
});
Ok(tmp)
}
fn rebuild_flat(&mut self) {
let keep = self.flat.get(self.selected).map(|r| r.path.clone());
let mut flat: Vec<FlatRow> = Vec::new();
Self::flatten_into(&self.roots, 0, &mut flat);
self.flat = flat;
match keep.and_then(|p| self.flat.iter().position(|r| r.path == p)) {
Some(idx) => self.selected = idx,
None => self.selected = self.selected.min(self.flat.len().saturating_sub(1)),
}
self.clamp_scroll();
}
fn flatten_into(nodes: &[TreeNode], depth: usize, out: &mut Vec<FlatRow>) {
for n in nodes {
let is_empty_dir = n.is_dir
&& n.expanded
&& n.loaded
&& n.load_error.is_none()
&& n.children.is_empty();
out.push(FlatRow {
path: n.path.clone(),
name: n.name.clone(),
is_dir: n.is_dir,
expanded: n.expanded,
depth,
has_error: n.load_error.is_some(),
is_empty_dir,
});
if n.is_dir && n.expanded {
Self::flatten_into(&n.children, depth + 1, out);
}
}
}
fn find_node_mut<'a>(
nodes: &'a mut [TreeNode],
path: &std::path::Path,
) -> Option<&'a mut TreeNode> {
for n in nodes.iter_mut() {
if n.path == path {
return Some(n);
}
if n.is_dir && n.expanded {
if let Some(found) = Self::find_node_mut(&mut n.children, path) {
return Some(found);
}
}
}
None
}
pub fn toggle_expand(&mut self) {
let path = match self.flat.get(self.selected) {
Some(r) if r.is_dir => r.path.clone(),
_ => return,
};
let show_hidden = self.show_hidden;
if let Some(node) = Self::find_node_mut(&mut self.roots, &path) {
if !node.expanded && !node.loaded {
match Self::read_children(&node.path, show_hidden) {
Ok(children) => {
node.children = children;
node.loaded = true;
node.load_error = None;
}
Err(e) => {
node.loaded = true;
node.load_error = Some(e.to_string());
node.children.clear();
}
}
}
node.expanded = !node.expanded;
}
self.rebuild_flat();
}
pub fn move_up(&mut self) {
if self.selected > 0 {
self.selected -= 1;
}
self.clamp_scroll();
}
pub fn move_down(&mut self) {
if self.selected + 1 < self.flat.len() {
self.selected += 1;
}
self.clamp_scroll();
}
fn clamp_scroll(&mut self) {
let visible = ATTACH_VISIBLE_ROWS;
if self.selected < self.scroll {
self.scroll = self.selected;
} else if self.selected >= self.scroll + visible {
self.scroll = self.selected + 1 - visible;
}
let max_scroll = self.flat.len().saturating_sub(visible);
if self.scroll > max_scroll {
self.scroll = max_scroll;
}
}
pub fn expand(&mut self) {
match self.flat.get(self.selected) {
Some(r) if r.is_dir && !r.expanded => self.toggle_expand(),
Some(r) if r.is_dir && r.expanded => self.move_down(),
_ => {}
}
}
pub fn collapse_or_parent(&mut self) {
match self.flat.get(self.selected) {
Some(r) if r.is_dir && r.expanded => self.toggle_expand(),
Some(r) => {
let depth = r.depth;
if depth > 0 {
let mut i = self.selected;
while i > 0 {
i -= 1;
if self.flat[i].depth < depth {
self.selected = i;
break;
}
}
self.clamp_scroll();
}
}
None => {}
}
}
pub fn toggle_hidden(&mut self) {
self.show_hidden = !self.show_hidden;
self.load_root();
}
pub fn selected_path(&self) -> Option<std::path::PathBuf> {
let r = self.flat.get(self.selected)?;
if r.is_dir {
None
} else {
Some(r.path.clone())
}
}
}
#[cfg(test)]
mod attach_picker_tests {
use super::{AttachPickerState, TreeNode};
use std::path::PathBuf;
fn dir(name: &str, children: Vec<TreeNode>) -> TreeNode {
TreeNode {
name: name.into(),
path: PathBuf::from(name),
is_dir: true,
expanded: false,
loaded: true, load_error: None,
children,
}
}
fn file(name: &str) -> TreeNode {
TreeNode {
name: name.into(),
path: PathBuf::from(name),
is_dir: false,
expanded: false,
loaded: true,
load_error: None,
children: vec![],
}
}
fn state(roots: Vec<TreeNode>) -> AttachPickerState {
let mut s = AttachPickerState {
root: PathBuf::from("/"),
roots,
flat: vec![],
selected: 0,
scroll: 0,
show_hidden: false,
error: None,
};
s.rebuild_flat();
s
}
#[test]
fn expand_collapse_preserves_cursor() {
let mut s = state(vec![dir("a", vec![file("x")]), file("b")]);
assert_eq!(s.flat.len(), 2);
s.selected = 0;
s.toggle_expand();
assert_eq!(s.flat.len(), 3);
assert_eq!(s.flat[s.selected].name, "a");
assert_eq!(s.flat[1].name, "x");
assert_eq!(s.flat[1].depth, 1);
s.move_down();
s.move_down();
assert_eq!(s.flat[s.selected].name, "b");
s.selected = 0;
s.toggle_expand();
assert_eq!(s.flat.len(), 2);
assert_eq!(s.flat[s.selected].name, "a");
}
#[test]
fn selected_path_is_none_for_dirs() {
let mut s = state(vec![dir("a", vec![file("x")]), file("b")]);
s.selected = 0; assert!(s.selected_path().is_none());
s.selected = 1; assert_eq!(s.selected_path(), Some(PathBuf::from("b")));
}
}
#[derive(Debug, Clone, Default)]
pub struct DialPeerState {
pub address: String,
pub status: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StartField {
Name,
Encrypted,
Passphrase,
}
#[derive(Debug, Clone)]
pub struct StartRoomState {
pub name: String,
pub encrypted: bool,
pub passphrase: String,
pub focus: StartField,
}
impl StartRoomState {
pub fn new() -> Self {
Self {
name: String::new(),
encrypted: false,
passphrase: String::new(),
focus: StartField::Name,
}
}
}
#[derive(Debug, Clone)]
pub struct JoinRoomState {
pub room_id: String,
pub room_name: String,
pub encrypted: bool,
pub passphrase: String,
}
const TYPING_DEBOUNCE: Duration = Duration::from_millis(800);
pub struct OpenRoom {
pub room_id: String,
pub name: String,
pub encrypted: bool,
pub members: Vec<String>,
pub messages: Vec<StoredRoomMessage>,
pub attachments: Vec<StoredAttachment>,
pub input: String,
pub input_active: bool,
pub scroll: u16,
pub last_typing_sent: Option<Instant>,
pub follow_mode: bool,
pub last_max_scroll: Cell<u16>,
pub card_focus: bool,
pub focused_card_idx: usize,
pub unread: u32,
}
#[derive(Debug, Clone)]
pub struct StatusEntry {
pub message: String,
pub timestamp: i64,
}
pub struct TuiApp {
pub handle: AppHandle,
pub mode: NetworkMode,
pub pane: Pane,
pub sidebar: SidebarState,
pub theme: crate::ui::theme::Theme,
pub unread: HashMap<String, u32>,
pub show_member_margin: bool,
pub people_focus: PeopleFocus,
pub selected_known_idx: usize,
pub selected_blocked_idx: usize,
pub selected_pending_idx: usize,
pub selected_contact_request_idx: usize,
pub pending_requests: Vec<huddle_core::storage::repo::PendingFriendRequest>,
pub pending_contact_requests: Vec<huddle_core::storage::repo::PendingContactRequest>,
pub modal: Modal,
pub pending_modals: VecDeque<Modal>,
pub discovered_rooms: Vec<DiscoveredRoom>,
pub known_peers: Vec<KnownPeerStatus>,
pub open_rooms: Vec<OpenRoom>,
pub listen_addresses: Vec<String>,
pub status_message: Option<(String, Instant)>,
pub status_history: VecDeque<StatusEntry>,
pub help_scroll: u16,
pub update_banner: Option<String>,
pub update_check_slot: Arc<Mutex<Option<String>>>,
pub nat_status: Option<String>,
pub went_dark_at: Option<Instant>,
pub startup_catchup_count: u32,
pub startup_grace_until: Option<Instant>,
pub startup_grace_cap: Instant,
pub settings_tab: SettingsTab,
pub profile_cursor: usize,
}
pub const GO_DARK_FAREWELL: Duration = Duration::from_secs(2);
pub const STARTUP_GRACE: Duration = Duration::from_secs(5);
pub const STARTUP_GRACE_EXTEND: Duration = Duration::from_secs(2);
pub const STARTUP_GRACE_MAX: Duration = Duration::from_secs(30);
impl TuiApp {
pub fn new(handle: AppHandle) -> Self {
let mode = handle.mode();
let known_peers = handle.known_peers();
let pending_requests = handle.list_pending_friend_requests();
let pending_contact_requests = handle.list_pending_contact_requests();
let last_seen = handle.last_seen_onboarding_version();
let legacy_seen = handle.onboarding_seen();
let pages = pages_to_show(last_seen.as_deref(), legacy_seen);
let mut pending_modals: VecDeque<Modal> = VecDeque::new();
if !pages.is_empty() {
pending_modals.push_back(Modal::Onboarding { pages, cursor: 0 });
}
if handle.update_check_enabled().is_none() && legacy_seen {
pending_modals.push_back(Modal::UpdateCheckOptIn);
}
Self {
handle,
mode,
pane: Pane::Welcome,
sidebar: SidebarState::default(),
theme: crate::ui::theme::Theme::dark(),
unread: HashMap::new(),
show_member_margin: true,
people_focus: PeopleFocus::default(),
selected_known_idx: 0,
selected_blocked_idx: 0,
selected_pending_idx: 0,
selected_contact_request_idx: 0,
pending_requests,
pending_contact_requests,
modal: Modal::None,
pending_modals,
discovered_rooms: Vec::new(),
known_peers,
open_rooms: Vec::new(),
listen_addresses: Vec::new(),
status_message: None,
status_history: VecDeque::new(),
help_scroll: 0,
update_banner: None,
update_check_slot: Arc::new(Mutex::new(None)),
nat_status: None,
went_dark_at: None,
startup_catchup_count: 0,
startup_grace_until: Some(Instant::now() + STARTUP_GRACE),
startup_grace_cap: Instant::now() + STARTUP_GRACE_MAX,
settings_tab: SettingsTab::default(),
profile_cursor: 0,
}
}
pub fn open_room(&self, room_id: &str) -> Option<&OpenRoom> {
self.open_rooms.iter().find(|r| r.room_id == room_id)
}
pub fn open_room_mut(&mut self, room_id: &str) -> Option<&mut OpenRoom> {
self.open_rooms.iter_mut().find(|r| r.room_id == room_id)
}
pub fn current_pane_room_id(&self) -> Option<&str> {
match &self.pane {
Pane::Dm(id) | Pane::Group(id) => Some(id.as_str()),
_ => None,
}
}
pub fn mode_str(&self) -> &'static str {
match self.mode {
NetworkMode::Server => "Tor onion (relay-only)",
NetworkMode::Mdns => "LAN (mDNS) + relay",
NetworkMode::Direct => "Direct dial + relay",
}
}
pub fn libp2p_active(&self) -> bool {
self.mode != NetworkMode::Server
}
pub fn clear_unread(&mut self, room_id: &str) {
self.unread.remove(room_id);
}
pub fn switch_to_room(&mut self, room_id: &str) {
let kind = self
.handle
.active_room_info(room_id)
.map(|r| r.kind)
.or_else(|| {
self.handle
.discovered_rooms()
.into_iter()
.find(|d| d.room_id == room_id)
.map(|d| d.kind)
})
.unwrap_or(huddle_core::storage::repo::RoomKind::Group);
self.pane = match kind {
huddle_core::storage::repo::RoomKind::Direct => Pane::Dm(room_id.to_string()),
huddle_core::storage::repo::RoomKind::Group => Pane::Group(room_id.to_string()),
};
self.clear_unread(room_id);
self.sidebar.focus = SidebarFocus::Pane;
}
pub fn refresh_known_peers(&mut self) {
self.known_peers = self.handle.known_peers();
if self.selected_known_idx >= self.known_peers.len() && !self.known_peers.is_empty() {
self.selected_known_idx = self.known_peers.len() - 1;
}
}
pub fn refresh_pending_requests(&mut self) {
self.pending_requests = self.handle.list_pending_friend_requests();
if self.selected_pending_idx >= self.pending_requests.len()
&& !self.pending_requests.is_empty()
{
self.selected_pending_idx = self.pending_requests.len() - 1;
}
}
pub fn refresh_pending_contact_requests(&mut self) {
self.pending_contact_requests = self.handle.list_pending_contact_requests();
}
pub fn set_status(&mut self, msg: impl Into<String>) {
let msg = msg.into();
self.record_status(&msg);
self.status_message = Some((msg, Instant::now() + STATUS_TTL));
}
pub fn set_status_for(&mut self, msg: impl Into<String>, ttl: Duration) {
let msg = msg.into();
self.record_status(&msg);
self.status_message = Some((msg, Instant::now() + ttl));
}
fn record_status(&mut self, msg: &str) {
if let Some(last) = self.status_history.back() {
if last.message == msg {
return;
}
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
self.status_history.push_back(StatusEntry {
message: msg.to_string(),
timestamp: now,
});
while self.status_history.len() > STATUS_HISTORY_CAP {
self.status_history.pop_front();
}
}
pub fn current_status(&self) -> Option<&str> {
self.status_message.as_ref().and_then(|(msg, exp)| {
if *exp > Instant::now() {
Some(msg.as_str())
} else {
None
}
})
}
pub fn tick_status(&mut self) {
if let Some((_, exp)) = &self.status_message {
if *exp <= Instant::now() {
self.status_message = None;
}
}
}
pub fn active_room(&self) -> Option<&OpenRoom> {
let id = self.current_pane_room_id()?;
self.open_room(id)
}
pub fn active_room_mut(&mut self) -> Option<&mut OpenRoom> {
let id = self.current_pane_room_id()?.to_string();
self.open_room_mut(&id)
}
pub fn refresh_discovered(&mut self) {
self.discovered_rooms = self.handle.discovered_rooms();
}
pub fn refresh_attachments(&mut self) {
let handle = self.handle.clone();
for room in &mut self.open_rooms {
room.attachments = handle.list_room_attachments(&room.room_id).unwrap_or_default();
if room.attachments.is_empty() {
room.focused_card_idx = 0;
room.card_focus = false;
} else if room.focused_card_idx >= room.attachments.len() {
room.focused_card_idx = room.attachments.len() - 1;
}
}
}
fn replace_modal_if_idle(&mut self, m: Modal) {
if matches!(self.modal, Modal::None | Modal::Error(_) | Modal::Info(_)) {
self.modal = m;
} else {
self.enqueue_modal(m);
}
}
pub fn enqueue_modal(&mut self, m: Modal) {
self.pending_modals.push_back(m);
while self.pending_modals.len() > PENDING_MODAL_CAP {
self.pending_modals.pop_front();
}
}
pub fn handle_app_event(&mut self, ev: AppEvent) {
match ev {
AppEvent::RoomDiscovered(_) | AppEvent::RoomLost { .. } => {
self.refresh_discovered();
}
AppEvent::RoomJoined { room_id } => {
let info = self.handle.active_room_info(&room_id);
let members = self.handle.room_members(&room_id);
let messages = self.handle.room_messages(&room_id, 200).unwrap_or_default();
let already_open = self.open_rooms.iter().any(|r| r.room_id == room_id);
if !already_open {
if let Some(info) = info {
let attachments = self
.handle
.list_room_attachments(&room_id)
.unwrap_or_default();
self.open_rooms.push(OpenRoom {
room_id: room_id.clone(),
name: info.name,
encrypted: info.encrypted,
members,
messages,
attachments,
input: String::new(),
input_active: false,
last_typing_sent: None,
scroll: 0,
follow_mode: true,
last_max_scroll: Cell::new(0),
card_focus: false,
focused_card_idx: 0,
unread: 0,
});
}
}
let soft_pane = matches!(self.pane, Pane::Welcome | Pane::Profile);
if soft_pane {
self.switch_to_room(&room_id);
}
}
AppEvent::RoomLeft { room_id } => {
if let Some(idx) = self.open_rooms.iter().position(|r| r.room_id == room_id) {
self.open_rooms.remove(idx);
if self.current_pane_room_id() == Some(room_id.as_str()) {
self.pane = Pane::Welcome;
}
self.unread.remove(&room_id);
}
}
AppEvent::MemberJoined { room_id, fingerprint } => {
if let Some(r) = self.open_rooms.iter_mut().find(|r| r.room_id == room_id) {
if !r.members.contains(&fingerprint) {
r.members.push(fingerprint);
r.members.sort();
}
}
}
AppEvent::MemberLeft { room_id, fingerprint } => {
if let Some(r) = self.open_rooms.iter_mut().find(|r| r.room_id == room_id) {
r.members.retain(|f| f != &fingerprint);
}
}
AppEvent::MessageReceived {
room_id,
sender_fingerprint,
body,
sent_at,
} => {
let is_active = self.current_pane_room_id() == Some(room_id.as_str());
let sender_for_notify = sender_fingerprint.clone();
let body_for_notify = body.clone();
if let Some(r) = self.open_room_mut(&room_id) {
r.messages.push(StoredRoomMessage {
id: 0,
room_id: room_id.clone(),
sender_fingerprint,
direction: "in".into(),
body,
sent_at,
});
}
if !is_active {
let count = self.unread.entry(room_id.clone()).or_insert(0);
*count = count.saturating_add(1);
}
if self.startup_grace_until.is_some() {
self.startup_catchup_count =
self.startup_catchup_count.saturating_add(1);
let extended = Instant::now() + STARTUP_GRACE_EXTEND;
let new_deadline = extended.min(self.startup_grace_cap);
self.startup_grace_until = self
.startup_grace_until
.map(|d| d.max(new_deadline));
} else if !crate::notifier::is_focused()
&& self.handle.notifications_enabled()
{
let room_name = self
.open_room(&room_id)
.map(|r| r.name.clone())
.or_else(|| {
self.handle.active_room_info(&room_id).map(|r| r.name)
})
.unwrap_or_else(|| short_room(&room_id));
let sender_name = self
.handle
.lookup_member_display_name(&sender_for_notify)
.unwrap_or_else(|| short_fp(&sender_for_notify));
let title = format!("huddle · {}", room_name);
let body = format!(
"{}: {}",
sender_name,
crate::notifier::preview(&body_for_notify)
);
crate::notifier::notify(&title, &body);
}
}
AppEvent::MessageSent {
room_id,
body,
message_id,
} => {
if let Some(r) = self.open_rooms.iter_mut().find(|r| r.room_id == room_id) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
r.messages.push(StoredRoomMessage {
id: message_id,
room_id: room_id.clone(),
sender_fingerprint: self.handle.fingerprint().to_string(),
direction: "out".into(),
body,
sent_at: now,
});
}
}
AppEvent::ListeningOn { address } => {
if !self.listen_addresses.contains(&address) {
self.listen_addresses.push(address);
}
}
AppEvent::PeerDiscovered { .. } => {}
AppEvent::PeerExpired { .. } => {
self.refresh_known_peers();
}
AppEvent::Dialing { address } => {
self.set_status(format!("dialing {}…", address));
if let Modal::DialPeer(s) = &mut self.modal {
s.status = Some(format!("dialing {}…", address));
}
}
AppEvent::DialSucceeded { address, .. } => {
self.set_status(format!("connected to {}", address));
if matches!(self.modal, Modal::DialPeer(_)) {
self.modal = Modal::None;
}
self.refresh_known_peers();
}
AppEvent::DialFailed { address, error } => {
let msg = format!("dial {} failed: {}", address, error);
self.set_status(msg.clone());
if matches!(self.modal, Modal::DialPeer(_)) {
self.modal = Modal::Error(msg);
}
self.refresh_known_peers();
}
AppEvent::Error { description } => {
self.replace_modal_if_idle(Modal::Error(description));
}
AppEvent::FileOffered {
room_id,
file_id: _,
name,
size_bytes,
sender_fingerprint: _,
} => {
let on_active = self.current_pane_room_id() == Some(room_id.as_str());
if !on_active {
let count = self.unread.entry(room_id.clone()).or_insert(0);
*count = count.saturating_add(1);
}
let _ = room_id;
self.set_status(format!(
"file offered: {} ({} KB)",
name,
size_bytes / 1024
));
}
AppEvent::FileProgress { .. } => {
}
AppEvent::FileReady { file_id: _ } => {
self.set_status("file ready — press Enter to save");
}
AppEvent::FileSaved { file_id: _, path } => {
self.set_status(format!("saved to {}", path));
}
AppEvent::FileFailed { file_id: _, reason } => {
self.set_status(format!("transfer failed: {}", reason));
}
AppEvent::TypingChanged { .. } => {
}
AppEvent::MentionReceived { room_id, body } => {
use std::io::Write;
let _ = write!(std::io::stdout(), "\x07");
let _ = std::io::stdout().flush();
self.set_status(format!("@you mentioned in #{}", short_room(&room_id)));
let _ = body;
}
AppEvent::RotationRequested {
room_id,
rotator_fingerprint,
new_salt,
} => {
self.replace_modal_if_idle(Modal::AcceptRotation(AcceptRotationState {
room_id,
rotator_fingerprint,
new_salt,
passphrase: String::new(),
}));
}
AppEvent::InboundDial {
peer_id,
fingerprint,
address,
} => {
self.replace_modal_if_idle(Modal::InboundDial(InboundDialState {
peer_id,
fingerprint,
address,
opened_at: Instant::now(),
}));
}
AppEvent::SasCodeReady {
room_id,
partner_fingerprint,
tx_id,
emoji_labels,
decimal,
} => {
let advanced = if let Modal::Sas(s) = &mut self.modal {
if s.tx_id == tx_id {
s.stage = SasStage::Comparing {
emoji_labels: emoji_labels.clone(),
decimal: decimal.clone(),
our_matched: false,
};
true
} else {
false
}
} else {
false
};
if !advanced {
self.replace_modal_if_idle(Modal::Sas(SasState {
room_id,
partner_fingerprint,
tx_id,
stage: SasStage::Comparing {
emoji_labels,
decimal,
our_matched: false,
},
}));
}
}
AppEvent::SasVerified {
partner_fingerprint,
..
} => {
if matches!(self.modal, Modal::Sas(_)) {
self.modal = Modal::None;
}
self.set_status(format!(
"✓ verified {} via SAS",
short_fp(&partner_fingerprint)
));
}
AppEvent::CodeJoinTimedOut { room_id: _, reason } => {
self.replace_modal_if_idle(Modal::Error(format!("code join: {reason}")));
}
AppEvent::InviteFingerprintMismatch {
address: _,
claimed,
actual,
} => {
let msg = format!(
"invite fingerprint mismatch — connection dropped.\nclaimed: {}\nactual: {}\nthe invite link may be forged.",
short_fp(&claimed),
short_fp(&actual)
);
self.replace_modal_if_idle(Modal::Error(msg));
}
AppEvent::NatStatusChanged { label, reachable: _ } => {
self.nat_status = Some(label);
}
AppEvent::DcutrSucceeded { peer_label } => {
self.set_status_for(
format!("direct connection to …{}", peer_label),
Duration::from_secs(10),
);
}
AppEvent::PeerProfileUpdated { fingerprint, username } => {
let new_label = match &username {
Some(n) if !n.is_empty() => n.clone(),
_ => "[anonymous]".into(),
};
let short: String = fingerprint.chars().take(4).collect();
self.set_status_for(
format!("{}… is now {}", short, new_label),
Duration::from_secs(4),
);
}
AppEvent::WentDark => {
self.modal = Modal::Info(
"Goodbye. huddle has gone dark. Restart to begin fresh.".into(),
);
self.went_dark_at = Some(std::time::Instant::now());
}
AppEvent::AutoOpenDm {
room_id,
fingerprint,
} => {
let label = self
.handle
.lookup_username(&fingerprint)
.unwrap_or_else(|| format!("HD-{}", short_fp(&fingerprint).to_uppercase()));
self.refresh_known_peers();
self.switch_to_room(&room_id);
self.set_status(format!("connected — chatting with {}", label));
}
AppEvent::ContactRequestReceived {
fingerprint,
display_name,
..
} => {
self.refresh_pending_contact_requests();
let who = display_name.unwrap_or_else(|| {
format!("HD-{}", short_fp(&fingerprint).to_uppercase())
});
self.set_status(format!("contact request from {} — see Contacts", who));
}
}
}
}
fn short_room(room_id: &str) -> String {
room_id.chars().take(8).collect()
}
fn short_fp(fp: &str) -> String {
fp.split('-').take(2).collect::<Vec<_>>().join("-")
}
fn owner_action_members(
app: &TuiApp,
kind: MemberActionKind,
) -> Option<(String, Vec<(String, bool)>)> {
let room_id = app.active_room()?.room_id.clone();
let our_fp = app.handle.fingerprint().to_string();
if !app.handle.is_owner(&room_id, &our_fp) {
return None;
}
let owners: std::collections::HashSet<String> =
app.handle.room_owners(&room_id).into_iter().collect();
let members: Vec<(String, bool)> = app
.active_room()?
.members
.iter()
.filter(|fp| *fp != &our_fp)
.filter(|fp| match kind {
MemberActionKind::Kick => true,
MemberActionKind::Grant => !owners.contains(*fp),
})
.map(|fp| (fp.clone(), owners.contains(fp)))
.collect();
if members.is_empty() {
return None;
}
Some((room_id, members))
}
fn sidebar_move(app: &mut TuiApp, delta: i32) {
let items = crate::ui::sidebar::ordered_items(app);
if items.is_empty() {
return;
}
let mut idx = items
.iter()
.position(|it| *it == app.sidebar.selection)
.unwrap_or(0) as i32;
idx += delta;
if idx < 0 {
idx = 0;
}
if idx as usize >= items.len() {
idx = items.len() as i32 - 1;
}
app.sidebar.selection = items[idx as usize].clone();
sync_pane_from_selection(app);
}
fn sidebar_jump_section(app: &mut TuiApp, delta: i32) {
use SidebarSection::*;
let order = [Profile, Direct, Group, People, Activity, Settings];
let current = match &app.sidebar.selection {
SidebarItem::Section(s) => *s,
SidebarItem::Profile => Profile,
SidebarItem::Dm(_) | SidebarItem::DirectAddFriend => Direct,
SidebarItem::Group(_) | SidebarItem::GroupDiscover | SidebarItem::GroupNew => Group,
SidebarItem::Person(_) | SidebarItem::PeoplePendingBadge => People,
SidebarItem::Activity => Activity,
SidebarItem::Settings => Settings,
};
let cur_idx = order.iter().position(|s| *s == current).unwrap_or(0) as i32;
let mut next = cur_idx + delta;
if next < 0 {
next = order.len() as i32 - 1;
} else if next as usize >= order.len() {
next = 0;
}
app.sidebar.selection = SidebarItem::Section(order[next as usize]);
}
fn sidebar_toggle_expand(app: &mut TuiApp) {
let section = match &app.sidebar.selection {
SidebarItem::Section(s) => *s,
SidebarItem::Profile => SidebarSection::Profile,
SidebarItem::Dm(_) | SidebarItem::DirectAddFriend => SidebarSection::Direct,
SidebarItem::Group(_) | SidebarItem::GroupDiscover | SidebarItem::GroupNew => {
SidebarSection::Group
}
SidebarItem::Person(_) | SidebarItem::PeoplePendingBadge => SidebarSection::People,
SidebarItem::Activity => SidebarSection::Activity,
SidebarItem::Settings => SidebarSection::Settings,
};
if app.sidebar.expanded.contains(§ion) {
app.sidebar.expanded.remove(§ion);
} else {
app.sidebar.expanded.insert(section);
}
}
fn sync_pane_from_selection(app: &mut TuiApp) {
if let Some(pane) = crate::ui::sidebar::pane_for_item(&app.sidebar.selection) {
match &pane {
Pane::Dm(id) | Pane::Group(id) => {
let id = id.clone();
app.clear_unread(&id);
if app.handle.active_room_info(&id).is_some() {
open_existing_room_tab_quiet(app, &id);
}
app.pane = pane;
}
_ => app.pane = pane,
}
}
}
pub fn profile_fields(app: &TuiApp) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = Vec::new();
let username = app
.handle
.display_name()
.unwrap_or_else(|| "[anonymous]".into());
out.push(("username".into(), username));
let hd = crate::ui::display_id(app.handle.fingerprint());
out.push(("HD-ID".into(), hd));
out.push(("Safety Code".into(), app.handle.safety_code()));
out.push(("fingerprint".into(), app.handle.fingerprint().to_string()));
let peer_id = app.handle.peer_id().to_string();
out.push(("peer-id".into(), peer_id.clone()));
for (i, addr) in app.listen_addresses.iter().take(6).enumerate() {
let dial = if addr.contains(&peer_id) {
addr.clone()
} else {
format!("{addr}/p2p/{peer_id}")
};
out.push((format!("dial address {}", i + 1), dial));
}
out
}
pub fn profile_field_count(app: &TuiApp) -> usize {
profile_fields(app).len()
}
pub fn profile_field_at(app: &TuiApp, idx: usize) -> Option<(String, String)> {
profile_fields(app).into_iter().nth(idx)
}
fn chat_room_ids(app: &TuiApp) -> Vec<String> {
let discovered = app.handle.discovered_rooms();
let mut dms: Vec<_> = discovered
.iter()
.filter(|r| r.kind == huddle_core::storage::repo::RoomKind::Direct)
.map(|r| r.room_id.clone())
.collect();
let mut groups: Vec<_> = discovered
.iter()
.filter(|r| r.kind != huddle_core::storage::repo::RoomKind::Direct)
.map(|r| r.room_id.clone())
.collect();
dms.append(&mut groups);
dms
}
fn switch_chat_relative(app: &mut TuiApp, delta: i32) {
let chats = chat_room_ids(app);
if chats.is_empty() {
return;
}
let current = app
.current_pane_room_id()
.and_then(|id| chats.iter().position(|c| c == id));
let next = match current {
Some(i) => {
let mut n = i as i32 + delta;
if n < 0 {
n = chats.len() as i32 - 1;
}
n as usize % chats.len()
}
None => 0,
};
let id = chats[next].clone();
app.switch_to_room(&id);
}
fn switch_chat_absolute(app: &mut TuiApp, n: usize) {
let chats = chat_room_ids(app);
if let Some(id) = chats.get(n).cloned() {
app.switch_to_room(&id);
}
}
fn open_existing_room_tab_quiet(app: &mut TuiApp, room_id: &str) {
if app.open_room(room_id).is_some() {
return;
}
let info = match app.handle.active_room_info(room_id) {
Some(i) => i,
None => return,
};
let members = app.handle.room_members(room_id);
let messages = app.handle.room_messages(room_id, 200).unwrap_or_default();
let attachments = app.handle.list_room_attachments(room_id).unwrap_or_default();
app.open_rooms.push(OpenRoom {
room_id: room_id.to_string(),
name: info.name,
encrypted: info.encrypted,
members,
messages,
attachments,
input: String::new(),
input_active: false,
last_typing_sent: None,
scroll: 0,
follow_mode: true,
last_max_scroll: Cell::new(0),
card_focus: false,
focused_card_idx: 0,
unread: 0,
});
}
fn scroll_by(app: &mut TuiApp, delta: i32) {
let r = match app.active_room_mut() {
Some(r) => r,
None => return,
};
let max = r.last_max_scroll.get();
let current = if r.follow_mode { max } else { r.scroll };
let next = if delta < 0 {
current.saturating_sub(delta.unsigned_abs() as u16)
} else {
current.saturating_add(delta as u16).min(max)
};
r.scroll = next;
r.follow_mode = next >= max;
}
fn open_existing_room_tab(app: &mut TuiApp, room_id: &str) {
let info = match app.handle.active_room_info(room_id) {
Some(i) => i,
None => return,
};
if app.open_room(room_id).is_none() {
let members = app.handle.room_members(room_id);
let messages = app.handle.room_messages(room_id, 200).unwrap_or_default();
let attachments = app.handle.list_room_attachments(room_id).unwrap_or_default();
app.open_rooms.push(OpenRoom {
room_id: room_id.to_string(),
name: info.name,
encrypted: info.encrypted,
members,
messages,
attachments,
input: String::new(),
input_active: false,
last_typing_sent: None,
scroll: 0,
follow_mode: true,
last_max_scroll: Cell::new(0),
card_focus: false,
focused_card_idx: 0,
unread: 0,
});
}
app.switch_to_room(room_id);
}
struct TerminalGuard;
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(
io::stdout(),
LeaveAlternateScreen,
DisableMouseCapture,
DisableFocusChange,
DisableBracketedPaste
);
}
}
pub fn install_panic_hook() {
let original = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = disable_raw_mode();
let _ = execute!(
io::stdout(),
LeaveAlternateScreen,
DisableMouseCapture,
DisableFocusChange,
DisableBracketedPaste
);
original(info);
}));
}
pub async fn run_tui(handle: AppHandle) -> Result<()> {
enable_raw_mode()?;
let _guard = TerminalGuard;
let mut stdout = io::stdout();
execute!(
stdout,
EnterAlternateScreen,
EnableMouseCapture,
EnableFocusChange,
EnableBracketedPaste
)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = TuiApp::new(handle);
if matches!(app.handle.update_check_enabled(), Some(true)) {
spawn_update_check(&app);
}
let mut event_rx = app.handle.subscribe();
let result = main_loop(&mut terminal, &mut app, &mut event_rx).await;
app.handle.shutdown().await;
result
}
pub struct AuthPrompt {
pub passphrase: String,
pub username: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AuthField {
Username,
Passphrase,
Confirm,
}
pub fn prompt_master_passphrase(is_new: bool) -> Result<AuthPrompt> {
use crossterm::event::{KeyCode, KeyModifiers};
use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph};
enable_raw_mode()?;
let _guard = TerminalGuard;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut username = String::new();
let mut passphrase = String::new();
let mut confirm = String::new();
let mut field = if is_new {
AuthField::Username
} else {
AuthField::Passphrase
};
let mut error: Option<String> = None;
let mut outcome: Option<AuthPrompt> = None;
while outcome.is_none() {
terminal.draw(|f| {
let height: u16 = if is_new { 18 } else { 12 };
let area = crate::ui::centered_rect(64, height, f.area());
f.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.padding(Padding::uniform(1))
.title(Span::styled(
if is_new {
" welcome to huddle — sign up "
} else {
" unlock huddle "
},
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
));
let masked = |s: &str| -> String { s.chars().map(|_| '•').collect() };
let label_style = |is_focused: bool| {
if is_focused {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
}
};
let value_style = Style::default().fg(Color::White);
let cursor = Span::styled("_", Style::default().fg(Color::DarkGray));
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(""));
if is_new {
lines.push(Line::from(Span::styled(
" pick a username (display name in chat — you can change it later)",
Style::default().fg(Color::DarkGray),
)));
lines.push(Line::from(Span::styled(
" and a passphrase that encrypts your local database.",
Style::default().fg(Color::DarkGray),
)));
lines.push(Line::from(Span::styled(
" forget the passphrase and your data is unrecoverable.",
Style::default().fg(Color::DarkGray),
)));
lines.push(Line::from(""));
let u_focused = field == AuthField::Username;
lines.push(Line::from(vec![
Span::styled(" username: ", label_style(u_focused)),
Span::styled(username.clone(), value_style),
if u_focused {
cursor.clone()
} else {
Span::raw("")
},
]));
let p_focused = field == AuthField::Passphrase;
lines.push(Line::from(vec![
Span::styled(" passphrase: ", label_style(p_focused)),
Span::styled(masked(&passphrase), value_style),
if p_focused {
cursor.clone()
} else {
Span::raw("")
},
]));
let c_focused = field == AuthField::Confirm;
lines.push(Line::from(vec![
Span::styled(" confirm: ", label_style(c_focused)),
Span::styled(masked(&confirm), value_style),
if c_focused {
cursor.clone()
} else {
Span::raw("")
},
]));
} else {
lines.push(Line::from(Span::styled(
" enter your passphrase to unlock the database.",
Style::default().fg(Color::DarkGray),
)));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" passphrase: ", label_style(true)),
Span::styled(masked(&passphrase), value_style),
cursor.clone(),
]));
}
if let Some(err) = &error {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" ! {}", err),
Style::default().fg(Color::Red),
)));
}
lines.push(Line::from(""));
let hint_label = if is_new {
if field == AuthField::Confirm {
" sign up "
} else {
" next field "
}
} else {
" unlock "
};
lines.push(Line::from(vec![
Span::styled(" Enter", Style::default().fg(Color::Yellow)),
Span::styled(hint_label, Style::default().fg(Color::DarkGray)),
Span::styled("Tab", Style::default().fg(Color::Yellow)),
Span::styled(" cycle fields ", Style::default().fg(Color::DarkGray)),
Span::styled("Esc", Style::default().fg(Color::Yellow)),
Span::styled(" cancel", Style::default().fg(Color::DarkGray)),
]));
f.render_widget(Paragraph::new(lines).block(block), area);
})?;
if event::poll(Duration::from_millis(200))? {
if let Event::Key(key) = event::read()? {
if key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('c'))
{
outcome = Some(AuthPrompt {
passphrase: String::new(),
username: None,
});
break;
}
match key.code {
KeyCode::Esc => {
outcome = Some(AuthPrompt {
passphrase: String::new(),
username: None,
});
}
KeyCode::Tab if is_new => {
field = match field {
AuthField::Username => AuthField::Passphrase,
AuthField::Passphrase => AuthField::Confirm,
AuthField::Confirm => AuthField::Username,
};
}
KeyCode::Backspace => match field {
AuthField::Username => {
username.pop();
}
AuthField::Passphrase => {
passphrase.pop();
}
AuthField::Confirm => {
confirm.pop();
}
},
KeyCode::Enter => {
if is_new {
match field {
AuthField::Username => {
if username.trim().is_empty() {
error = Some("username can't be empty".into());
} else {
error = None;
field = AuthField::Passphrase;
}
}
AuthField::Passphrase => {
if passphrase.is_empty() {
error = Some("passphrase can't be empty".into());
} else {
error = None;
field = AuthField::Confirm;
}
}
AuthField::Confirm => {
if confirm != passphrase {
error = Some("passphrases don't match — try again".into());
confirm.clear();
} else {
outcome = Some(AuthPrompt {
passphrase: passphrase.clone(),
username: Some(username.trim().to_string()),
});
}
}
}
} else if passphrase.is_empty() {
error = Some("passphrase can't be empty".into());
} else {
outcome = Some(AuthPrompt {
passphrase: passphrase.clone(),
username: None,
});
}
}
KeyCode::Char(c) => match field {
AuthField::Username => username.push(c),
AuthField::Passphrase => passphrase.push(c),
AuthField::Confirm => confirm.push(c),
},
_ => {}
}
}
}
}
Ok(outcome.unwrap_or(AuthPrompt {
passphrase: String::new(),
username: None,
}))
}
pub fn show_welcome() -> Result<bool> {
use crossterm::event::{KeyCode, KeyModifiers};
enable_raw_mode()?;
let _guard = TerminalGuard;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let outcome = loop {
terminal.draw(crate::ui::picker::render_welcome)?;
if poll(Duration::from_millis(200))? {
if let Event::Key(key) = event::read()? {
if key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('c'))
{
break false;
}
match key.code {
KeyCode::Char('q') => break false,
_ => break true,
}
}
}
};
Ok(outcome)
}
async fn main_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut TuiApp,
event_rx: &mut tokio::sync::broadcast::Receiver<AppEvent>,
) -> Result<()> {
let mut should_quit = false;
let mut last_refresh = std::time::Instant::now();
while !should_quit {
terminal.draw(|f| crate::ui::render(f, app))?;
while let Ok(ev) = event_rx.try_recv() {
app.handle_app_event(ev);
}
if last_refresh.elapsed() > Duration::from_secs(1) {
app.refresh_discovered();
app.refresh_attachments();
last_refresh = std::time::Instant::now();
}
app.tick_status();
if let Some(deadline) = app.startup_grace_until {
if Instant::now() >= deadline {
let n = app.startup_catchup_count;
app.startup_catchup_count = 0;
app.startup_grace_until = None;
if n > 0 && app.handle.notifications_enabled() {
let body = if n == 1 {
"1 new message while you were away".to_string()
} else {
format!("{} new messages while you were away", n)
};
crate::notifier::notify("huddle", &body);
}
}
}
if app.update_banner.is_none() {
if let Ok(mut slot) = app.update_check_slot.lock() {
if let Some(v) = slot.take() {
app.update_banner = Some(v);
}
}
}
if let Some(t) = app.went_dark_at {
if t.elapsed() >= GO_DARK_FAREWELL {
should_quit = true;
continue;
}
}
let auto_reject_state: Option<InboundDialState> =
if let Modal::InboundDial(s) = &app.modal {
if s.opened_at.elapsed() >= Duration::from_secs(15) {
Some(s.clone())
} else {
None
}
} else {
None
};
if let Some(s) = auto_reject_state {
if let Err(e) =
app.handle
.spill_pending_friend_request(s.peer_id, &s.fingerprint, &s.address)
{
tracing::warn!(%e, "failed to spill pending friend request");
}
app.handle.disconnect_peer(s.peer_id).await;
app.set_status(format!(
"saved request from {} — review in People → Pending",
short_fp(&s.fingerprint)
));
app.modal = Modal::None;
app.refresh_pending_requests();
}
if poll(Duration::from_millis(33))? {
match event::read()? {
Event::Key(key) => {
let action = input::map_key(key, app);
should_quit = handle_action(action, app).await?;
}
Event::Mouse(m) => {
if matches!(m.kind, MouseEventKind::Down(MouseButton::Left))
&& app.current_pane_room_id().is_some()
{
if let Some(r) = app.active_room() {
if !r.attachments.is_empty() && !r.card_focus {
handle_action(input::Action::ToggleCardFocus, app).await?;
}
}
}
}
Event::Paste(text) => {
should_quit = handle_paste(text, app).await?;
}
Event::FocusGained => crate::notifier::set_focused(true),
Event::FocusLost => crate::notifier::set_focused(false),
_ => {}
}
}
if matches!(app.modal, Modal::None) {
if let Some(m) = app.pending_modals.pop_front() {
app.modal = m;
}
}
}
Ok(())
}
async fn handle_paste(text: String, app: &mut TuiApp) -> Result<bool> {
let files = parse_dropped_paths(&text);
if !files.is_empty() {
let room_id = match app.active_room() {
Some(r) => r.room_id.clone(),
None => {
app.set_status("open a room to attach a dropped file");
return Ok(false);
}
};
app.modal = Modal::None;
let mut sent = 0usize;
for path in &files {
match app.handle.send_file(&room_id, path).await {
Ok(_) => sent += 1,
Err(e) => app.set_status(format!("attach failed for {}: {e}", path.display())),
}
}
if sent == 1 {
app.set_status(format!("sending {}", files[0].display()));
} else if sent > 1 {
app.set_status(format!("sending {sent} files"));
}
return Ok(false);
}
for c in text.chars() {
if c == '\n' || c == '\r' || c.is_control() {
continue;
}
let key = KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
let action = input::map_key(key, app);
if handle_action(action, app).await? {
return Ok(true);
}
}
Ok(false)
}
fn parse_dropped_paths(text: &str) -> Vec<std::path::PathBuf> {
tokenize_paste(text)
.into_iter()
.map(std::path::PathBuf::from)
.filter(|p| p.is_file())
.collect()
}
fn tokenize_paste(text: &str) -> Vec<String> {
let trimmed = text.trim();
if trimmed.is_empty() {
return Vec::new();
}
let mut tokens: Vec<String> = Vec::new();
let mut cur = String::new();
let mut in_single = false;
let mut in_double = false;
let mut chars = trimmed.chars();
while let Some(c) = chars.next() {
match c {
'\\' if !in_single => {
if let Some(n) = chars.next() {
cur.push(n);
}
}
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
c if c.is_whitespace() && !in_single && !in_double => {
if !cur.is_empty() {
tokens.push(std::mem::take(&mut cur));
}
}
c => cur.push(c),
}
}
if !cur.is_empty() {
tokens.push(cur);
}
tokens
}
#[cfg(test)]
mod paste_tests {
use super::tokenize_paste;
#[test]
fn unescapes_backslash_spaces() {
assert_eq!(
tokenize_paste("/Users/me/My\\ File.txt"),
vec!["/Users/me/My File.txt"]
);
}
#[test]
fn strips_surrounding_quotes() {
assert_eq!(
tokenize_paste("'/path/with space.txt'"),
vec!["/path/with space.txt"]
);
assert_eq!(
tokenize_paste("\"/path/two words.png\""),
vec!["/path/two words.png"]
);
}
#[test]
fn splits_multiple_unquoted_paths() {
assert_eq!(
tokenize_paste("/a/one.txt /b/two.txt"),
vec!["/a/one.txt", "/b/two.txt"]
);
}
#[test]
fn plain_text_tokenizes_but_wont_be_files() {
assert_eq!(tokenize_paste("hello world"), vec!["hello", "world"]);
}
}
async fn handle_action(action: Action, app: &mut TuiApp) -> Result<bool> {
match action {
Action::Nothing => Ok(false),
Action::Quit => Ok(true),
Action::OpenQuitConfirm => {
app.modal = Modal::QuitConfirm;
Ok(false)
}
Action::OpenClearBlockedConfirm => {
app.modal = Modal::ConfirmClearBlocked;
Ok(false)
}
Action::CloseModal => {
app.modal = Modal::None;
Ok(false)
}
Action::OpenStartRoom => {
app.modal = Modal::StartRoom(StartRoomState::new());
Ok(false)
}
Action::OpenHelp => {
app.help_scroll = 0;
app.modal = Modal::Help;
Ok(false)
}
Action::LobbyNavigateUp => {
sidebar_move(app, -1);
Ok(false)
}
Action::LobbyNavigateDown => {
sidebar_move(app, 1);
Ok(false)
}
Action::LobbyRefresh => {
app.refresh_discovered();
app.refresh_known_peers();
Ok(false)
}
Action::LobbyFocusToggle => {
sidebar_jump_section(app, 1);
Ok(false)
}
Action::FocusSidebar => {
if let Some(r) = app.active_room_mut() {
r.input_active = false;
}
app.sidebar.focus = SidebarFocus::Sidebar;
Ok(false)
}
Action::FocusPane => {
app.sidebar.focus = SidebarFocus::Pane;
if matches!(app.pane, Pane::Dm(_) | Pane::Group(_)) {
if let Some(r) = app.active_room_mut() {
r.input_active = true;
}
}
Ok(false)
}
Action::LobbyReconnectPeer => {
if let Some(p) = app.known_peers.get(app.selected_known_idx).cloned() {
if let Err(e) = app.handle.redial(&p.address).await {
app.modal = Modal::Error(format!("dial failed: {e}"));
}
}
Ok(false)
}
Action::LobbyForgetPeer => {
if let Some(p) = app.known_peers.get(app.selected_known_idx).cloned() {
if let Err(e) = app.handle.forget_peer(&p.address).await {
app.modal = Modal::Error(format!("forget failed: {e}"));
}
app.refresh_known_peers();
if app.selected_known_idx >= app.known_peers.len() && !app.known_peers.is_empty() {
app.selected_known_idx = app.known_peers.len() - 1;
}
}
Ok(false)
}
Action::OpenDialPeer => {
app.modal = Modal::DialPeer(DialPeerState::default());
Ok(false)
}
Action::DialPeerTypeChar(c) => {
if let Modal::DialPeer(s) = &mut app.modal {
s.address.push(c);
}
Ok(false)
}
Action::DialPeerBackspace => {
if let Modal::DialPeer(s) = &mut app.modal {
s.address.pop();
}
Ok(false)
}
Action::DialPeerConfirm => {
let address = match &app.modal {
Modal::DialPeer(s) => s.address.clone(),
_ => return Ok(false),
};
if address.trim().is_empty() {
if let Modal::DialPeer(s) = &mut app.modal {
s.status = Some("address is empty".into());
}
return Ok(false);
}
match app.handle.dial(&address).await {
Ok(()) => {
if let Modal::DialPeer(s) = &mut app.modal {
s.status = Some(format!("dialing {}…", address));
}
}
Err(e) => {
app.modal = Modal::Error(format!("invalid address: {e}"));
}
}
Ok(false)
}
Action::LobbyJoinSelected => {
match app.sidebar.selection.clone() {
SidebarItem::Dm(room_id) | SidebarItem::Group(room_id) => {
if app.handle.active_room_info(&room_id).is_some() {
open_existing_room_tab(app, &room_id);
return Ok(false);
}
let room = app
.handle
.discovered_rooms()
.into_iter()
.find(|d| d.room_id == room_id);
if let Some(room) = room {
if room.encrypted {
app.modal = Modal::JoinRoom(JoinRoomState {
room_id: room.room_id.clone(),
room_name: room.name.clone(),
encrypted: true,
passphrase: String::new(),
});
} else if let Err(e) = app.handle.join_room(&room.room_id, None).await {
app.modal = Modal::Error(format!("join failed: {e}"));
}
}
}
SidebarItem::Section(s) => {
if app.sidebar.expanded.contains(&s) {
app.sidebar.expanded.remove(&s);
} else {
app.sidebar.expanded.insert(s);
}
}
SidebarItem::Profile => app.pane = Pane::Profile,
SidebarItem::Person(_) => app.pane = Pane::People,
SidebarItem::Activity => app.pane = Pane::Activity,
SidebarItem::Settings => app.pane = Pane::Settings,
SidebarItem::DirectAddFriend => {
return Box::pin(handle_action(Action::OpenComposeDm, app)).await;
}
SidebarItem::GroupNew => {
return Box::pin(handle_action(Action::OpenStartRoom, app)).await;
}
SidebarItem::PeoplePendingBadge => {
return Box::pin(handle_action(Action::JumpToPeoplePane, app)).await;
}
SidebarItem::GroupDiscover => {
if let Some(room) = app
.handle
.discovered_rooms()
.into_iter()
.find(|r| {
r.kind != huddle_core::storage::repo::RoomKind::Direct
&& !app
.handle
.active_room_ids()
.iter()
.any(|aid| aid == &r.room_id)
})
{
if room.encrypted {
app.modal = Modal::JoinRoom(JoinRoomState {
room_id: room.room_id.clone(),
room_name: room.name.clone(),
encrypted: true,
passphrase: String::new(),
});
} else if let Err(e) = app.handle.join_room(&room.room_id, None).await {
app.modal = Modal::Error(format!("join failed: {e}"));
}
}
}
}
Ok(false)
}
Action::StartRoomNextField => {
if let Modal::StartRoom(s) = &mut app.modal {
s.focus = match s.focus {
StartField::Name => StartField::Encrypted,
StartField::Encrypted => {
if s.encrypted {
StartField::Passphrase
} else {
StartField::Name
}
}
StartField::Passphrase => StartField::Name,
};
}
Ok(false)
}
Action::StartRoomToggleEncrypted => {
if let Modal::StartRoom(s) = &mut app.modal {
s.encrypted = !s.encrypted;
if !s.encrypted {
s.passphrase.clear();
if s.focus == StartField::Passphrase {
s.focus = StartField::Encrypted;
}
}
}
Ok(false)
}
Action::StartRoomTypeChar(c) => {
if let Modal::StartRoom(s) = &mut app.modal {
match s.focus {
StartField::Name => s.name.push(c),
StartField::Passphrase => s.passphrase.push(c),
StartField::Encrypted => {}
}
}
Ok(false)
}
Action::StartRoomBackspace => {
if let Modal::StartRoom(s) = &mut app.modal {
match s.focus {
StartField::Name => {
s.name.pop();
}
StartField::Passphrase => {
s.passphrase.pop();
}
StartField::Encrypted => {}
}
}
Ok(false)
}
Action::StartRoomConfirm => {
let (name, encrypted, passphrase) = match &app.modal {
Modal::StartRoom(s) => (s.name.clone(), s.encrypted, s.passphrase.clone()),
_ => return Ok(false),
};
if name.trim().is_empty() {
app.modal = Modal::Error("room name cannot be empty".into());
return Ok(false);
}
if encrypted && passphrase.is_empty() {
app.modal = Modal::Error("encrypted room requires a passphrase".into());
return Ok(false);
}
app.modal = Modal::None;
let pp = if encrypted { Some(passphrase.as_str()) } else { None };
if let Err(e) = app
.handle
.start_room(&name, encrypted, pp, huddle_core::storage::repo::RoomKind::Group)
.await
{
app.modal = Modal::Error(format!("start failed: {e}"));
}
Ok(false)
}
Action::JoinRoomTypeChar(c) => {
if let Modal::JoinRoom(j) = &mut app.modal {
j.passphrase.push(c);
}
Ok(false)
}
Action::JoinRoomBackspace => {
if let Modal::JoinRoom(j) = &mut app.modal {
j.passphrase.pop();
}
Ok(false)
}
Action::JoinRoomConfirm => {
let (room_id, passphrase) = match &app.modal {
Modal::JoinRoom(j) => (j.room_id.clone(), j.passphrase.clone()),
_ => return Ok(false),
};
app.modal = Modal::None;
if let Err(e) = app.handle.join_room(&room_id, Some(&passphrase)).await {
app.modal = Modal::Error(format!("join failed: {e}"));
}
Ok(false)
}
Action::TabNext => {
switch_chat_relative(app, 1);
Ok(false)
}
Action::TabPrev => {
switch_chat_relative(app, -1);
Ok(false)
}
Action::TabSelect(n) => {
switch_chat_absolute(app, n);
Ok(false)
}
Action::BackToLobby => {
app.sidebar.focus = SidebarFocus::Sidebar;
if matches!(app.pane, Pane::Dm(_) | Pane::Group(_)) {
} else {
app.pane = Pane::Welcome;
}
Ok(false)
}
Action::LeaveRoom => {
if let Some(room) = app.active_room() {
let id = room.room_id.clone();
match app.handle.leave_room(&id).await {
Ok(true) => {}
Ok(false) => app.set_status(
"left locally — peers may still see you until they time you out",
),
Err(e) => app.modal = Modal::Error(format!("leave failed: {e}")),
}
}
Ok(false)
}
Action::FocusInput => {
if let Some(r) = app.active_room_mut() {
r.input_active = true;
}
Ok(false)
}
Action::BlurInput => {
if let Some(r) = app.active_room_mut() {
r.input_active = false;
}
Ok(false)
}
Action::ScrollUp => {
scroll_by(app, -1);
Ok(false)
}
Action::ScrollDown => {
scroll_by(app, 1);
Ok(false)
}
Action::PageUp => {
scroll_by(app, -10);
Ok(false)
}
Action::PageDown => {
scroll_by(app, 10);
Ok(false)
}
Action::JumpTop => {
if let Some(r) = app.active_room_mut() {
r.scroll = 0;
r.follow_mode = false;
}
Ok(false)
}
Action::JumpBottom => {
if let Some(r) = app.active_room_mut() {
r.follow_mode = true;
}
Ok(false)
}
Action::ChatTypeChar(c) => {
let (room_id, should_pulse) = {
let r = match app.active_room_mut() {
Some(r) if r.input_active => r,
_ => return Ok(false),
};
r.input.push(c);
let pulse = match r.last_typing_sent {
Some(t) if t.elapsed() < TYPING_DEBOUNCE => false,
_ => true,
};
if pulse {
r.last_typing_sent = Some(Instant::now());
}
(r.room_id.clone(), pulse)
};
if should_pulse {
app.handle.broadcast_typing(&room_id).await;
}
Ok(false)
}
Action::ChatBackspace => {
if let Some(r) = app.active_room_mut() {
if r.input_active {
r.input.pop();
}
}
Ok(false)
}
Action::ChatSend => {
let (room_id, body) = {
match app.active_room_mut() {
Some(r) if r.input_active && !r.input.trim().is_empty() => {
let body = r.input.clone();
r.input.clear();
(r.room_id.clone(), body)
}
_ => return Ok(false),
}
};
if let Err(e) = app.handle.send_room_message(&room_id, &body).await {
app.modal = Modal::Error(format!("send failed: {e}"));
}
Ok(false)
}
Action::ChatInsertNewline => {
if let Some(r) = app.active_room_mut() {
if r.input_active {
r.input.push('\n');
}
}
Ok(false)
}
Action::ToggleCardFocus => {
if let Some(r) = app.active_room_mut() {
if r.attachments.is_empty() {
return Ok(false);
}
r.card_focus = !r.card_focus;
if r.card_focus {
r.input_active = false;
if r.focused_card_idx >= r.attachments.len() {
r.focused_card_idx = 0;
}
}
}
Ok(false)
}
Action::CardNext => {
if let Some(r) = app.active_room_mut() {
if !r.attachments.is_empty() {
r.focused_card_idx = (r.focused_card_idx + 1) % r.attachments.len();
}
}
Ok(false)
}
Action::CardPrev => {
if let Some(r) = app.active_room_mut() {
if !r.attachments.is_empty() {
r.focused_card_idx = if r.focused_card_idx == 0 {
r.attachments.len() - 1
} else {
r.focused_card_idx - 1
};
}
}
Ok(false)
}
Action::ActivateFocusedCard => {
let (room_id, file_id, status, encrypted) = match focused_card_info(app) {
Some(t) => t,
None => return Ok(false),
};
use huddle_core::storage::repo::AttachmentStatus;
match status {
AttachmentStatus::Offered | AttachmentStatus::Downloading => {
app.set_status("waiting for chunks…");
}
AttachmentStatus::Ready | AttachmentStatus::Saved => {
match app.handle.save_to_downloads(&room_id, &file_id).await {
Ok(path) => app.set_status(format!("saved to {}", path.display())),
Err(e) => app.modal = Modal::Error(format!("save failed: {e}")),
}
}
AttachmentStatus::Failed => {
app.set_status("retry not yet implemented — ask the sender to resend");
}
AttachmentStatus::Cancelled => {
app.set_status("transfer was cancelled");
}
}
let _ = encrypted;
Ok(false)
}
Action::OpenFocusedCard => {
let (room_id, file_id, _, _) = match focused_card_info(app) {
Some(t) => t,
None => return Ok(false),
};
if let Err(e) = app.handle.open_saved(&room_id, &file_id) {
app.modal = Modal::Error(format!("open failed: {e}"));
}
Ok(false)
}
Action::CancelFocusedCard => {
let (room_id, file_id, _, _) = match focused_card_info(app) {
Some(t) => t,
None => return Ok(false),
};
if let Err(e) = app.handle.cancel_transfer(&room_id, &file_id).await {
app.modal = Modal::Error(format!("cancel failed: {e}"));
}
Ok(false)
}
Action::SaveAgainFocusedCard => {
let (room_id, file_id, _, _) = match focused_card_info(app) {
Some(t) => t,
None => return Ok(false),
};
match app.handle.save_to_downloads(&room_id, &file_id).await {
Ok(path) => app.set_status(format!("saved to {}", path.display())),
Err(e) => app.modal = Modal::Error(format!("save failed: {e}")),
}
Ok(false)
}
Action::OpenAttachmentPicker => {
if app.active_room().is_none() {
app.set_status("attach is only available inside a room");
return Ok(false);
}
app.modal = Modal::AttachPicker(AttachPickerState::new());
Ok(false)
}
Action::AttachPickerUp => {
if let Modal::AttachPicker(s) = &mut app.modal {
s.move_up();
}
Ok(false)
}
Action::AttachPickerDown => {
if let Modal::AttachPicker(s) = &mut app.modal {
s.move_down();
}
Ok(false)
}
Action::AttachPickerToggle => {
if let Modal::AttachPicker(s) = &mut app.modal {
s.toggle_expand();
}
Ok(false)
}
Action::AttachPickerExpand => {
if let Modal::AttachPicker(s) = &mut app.modal {
s.expand();
}
Ok(false)
}
Action::AttachPickerCollapse => {
if let Modal::AttachPicker(s) = &mut app.modal {
s.collapse_or_parent();
}
Ok(false)
}
Action::AttachPickerToggleHidden => {
if let Modal::AttachPicker(s) = &mut app.modal {
s.toggle_hidden();
}
Ok(false)
}
Action::OpenRotateRoom => {
let room_id = match app.active_room() {
Some(r) if r.encrypted => r.room_id.clone(),
Some(_) => {
app.set_status("rotation only applies to encrypted rooms");
return Ok(false);
}
None => return Ok(false),
};
app.modal = Modal::RotateRoom(RotateRoomState {
room_id,
passphrase: String::new(),
});
Ok(false)
}
Action::RotateRoomTypeChar(c) => {
if let Modal::RotateRoom(s) = &mut app.modal {
s.passphrase.push(c);
}
Ok(false)
}
Action::RotateRoomBackspace => {
if let Modal::RotateRoom(s) = &mut app.modal {
s.passphrase.pop();
}
Ok(false)
}
Action::RotateRoomConfirm => {
let (room_id, pp) = match &app.modal {
Modal::RotateRoom(s) => (s.room_id.clone(), s.passphrase.clone()),
_ => return Ok(false),
};
if pp.is_empty() {
app.modal = Modal::Error("new passphrase cannot be empty".into());
return Ok(false);
}
app.modal = Modal::None;
match app.handle.rotate_room(&room_id, &pp).await {
Ok(()) => app.set_status("rotation broadcast — share the new passphrase out-of-band"),
Err(e) => app.modal = Modal::Error(format!("rotate failed: {e}")),
}
Ok(false)
}
Action::AcceptRotationTypeChar(c) => {
if let Modal::AcceptRotation(s) = &mut app.modal {
s.passphrase.push(c);
}
Ok(false)
}
Action::AcceptRotationBackspace => {
if let Modal::AcceptRotation(s) = &mut app.modal {
s.passphrase.pop();
}
Ok(false)
}
Action::AcceptRotationConfirm => {
let (room_id, new_salt, pp) = match &app.modal {
Modal::AcceptRotation(s) => {
(s.room_id.clone(), s.new_salt.clone(), s.passphrase.clone())
}
_ => return Ok(false),
};
if pp.is_empty() {
return Ok(false);
}
app.modal = Modal::None;
match app.handle.accept_rotation(&room_id, &new_salt, &pp).await {
Ok(()) => app.set_status("accepted rotation — new key in use"),
Err(e) => app.modal = Modal::Error(format!("accept rotation failed: {e}")),
}
Ok(false)
}
Action::OpenQrIdentity => {
app.modal = Modal::QrIdentity;
Ok(false)
}
Action::ToggleMute => {
let room_id = match app.active_room() {
Some(r) => r.room_id.clone(),
None => return Ok(false),
};
let now_muted = app.handle.is_room_muted(&room_id);
if let Err(e) = app.handle.set_room_muted(&room_id, !now_muted) {
app.modal = Modal::Error(format!("mute toggle failed: {e}"));
} else {
app.set_status(if !now_muted { "muted" } else { "unmuted" });
}
Ok(false)
}
Action::OpenSearch => {
let room_id = match app.active_room() {
Some(r) => r.room_id.clone(),
None => return Ok(false),
};
app.modal = Modal::Search(SearchState {
room_id,
query: String::new(),
results: Vec::new(),
selected: 0,
searched: false,
});
Ok(false)
}
Action::SearchTypeChar(c) => {
if let Modal::Search(s) = &mut app.modal {
s.query.push(c);
}
Ok(false)
}
Action::SearchBackspace => {
if let Modal::Search(s) = &mut app.modal {
s.query.pop();
}
Ok(false)
}
Action::SearchSubmit => {
let (room_id, query) = match &app.modal {
Modal::Search(s) => (s.room_id.clone(), s.query.clone()),
_ => return Ok(false),
};
if query.trim().is_empty() {
return Ok(false);
}
let results = app
.handle
.search_room_messages(&room_id, &query, 100)
.unwrap_or_default();
if let Modal::Search(s) = &mut app.modal {
s.results = results;
s.selected = 0;
s.searched = true;
}
Ok(false)
}
Action::SearchNext => {
if let Modal::Search(s) = &mut app.modal {
if s.selected + 1 < s.results.len() {
s.selected += 1;
}
}
Ok(false)
}
Action::SearchPrev => {
if let Modal::Search(s) = &mut app.modal {
if s.selected > 0 {
s.selected -= 1;
}
}
Ok(false)
}
Action::OpenVerify => {
let room_id = match app.active_room() {
Some(r) => r.room_id.clone(),
None => return Ok(false),
};
let our_fp = app.handle.fingerprint().to_string();
let verified_set: std::collections::HashSet<String> =
app.handle.verified_fingerprints(&room_id).into_iter().collect();
let members: Vec<(String, bool)> = app
.active_room()
.map(|r| {
r.members
.iter()
.filter(|fp| **fp != our_fp)
.map(|fp| (fp.clone(), verified_set.contains(fp)))
.collect()
})
.unwrap_or_default();
if members.is_empty() {
app.set_status("no other members to verify yet");
return Ok(false);
}
app.modal = Modal::Verify(VerifyState {
room_id,
our_fingerprint: our_fp,
members,
selected: 0,
});
Ok(false)
}
Action::VerifyNext => {
if let Modal::Verify(s) = &mut app.modal {
if s.selected + 1 < s.members.len() {
s.selected += 1;
}
}
Ok(false)
}
Action::VerifyPrev => {
if let Modal::Verify(s) = &mut app.modal {
if s.selected > 0 {
s.selected -= 1;
}
}
Ok(false)
}
Action::VerifyToggle => {
let (room_id, fp, new_state) = match &mut app.modal {
Modal::Verify(s) => {
let m = match s.members.get_mut(s.selected) {
Some(x) => x,
None => return Ok(false),
};
m.1 = !m.1;
(s.room_id.clone(), m.0.clone(), m.1)
}
_ => return Ok(false),
};
if let Err(e) = app.handle.set_member_verified(&room_id, &fp, new_state) {
app.modal = Modal::Error(format!("verify failed: {e}"));
}
Ok(false)
}
Action::OnboardingNext => {
if let Modal::Onboarding { pages, cursor } = &mut app.modal {
if *cursor + 1 < pages.len() {
*cursor += 1;
} else {
let _ = app.handle.mark_onboarding_seen();
let _ = app
.handle
.set_last_seen_onboarding_version(env!("CARGO_PKG_VERSION"));
app.modal = Modal::None;
}
}
Ok(false)
}
Action::OnboardingPrev => {
if let Modal::Onboarding { cursor, .. } = &mut app.modal {
if *cursor > 0 {
*cursor -= 1;
}
}
Ok(false)
}
Action::OnboardingDismiss => {
let _ = app.handle.mark_onboarding_seen();
let _ = app
.handle
.set_last_seen_onboarding_version(env!("CARGO_PKG_VERSION"));
app.modal = Modal::None;
Ok(false)
}
Action::OpenWhatsNew => {
let pages: Vec<usize> = (0..ONBOARDING_PAGES.len()).collect();
app.modal = Modal::Onboarding { pages, cursor: 0 };
Ok(false)
}
Action::OpenStatusHistory => {
app.modal = Modal::StatusHistory { scroll: 0 };
Ok(false)
}
Action::StatusHistoryScrollUp => {
if let Modal::StatusHistory { scroll } = &mut app.modal {
*scroll = scroll.saturating_sub(1);
}
Ok(false)
}
Action::StatusHistoryScrollDown => {
if let Modal::StatusHistory { scroll } = &mut app.modal {
*scroll = scroll.saturating_add(1);
}
Ok(false)
}
Action::StatusHistoryPageUp => {
if let Modal::StatusHistory { scroll } = &mut app.modal {
*scroll = scroll.saturating_sub(10);
}
Ok(false)
}
Action::StatusHistoryPageDown => {
if let Modal::StatusHistory { scroll } = &mut app.modal {
*scroll = scroll.saturating_add(10);
}
Ok(false)
}
Action::ClearStatusHistory => {
app.status_history.clear();
app.set_status("notification history cleared");
app.modal = Modal::None;
Ok(false)
}
Action::OpenCommandPalette => {
app.modal = Modal::CommandPalette(CommandPaletteState::default());
Ok(false)
}
Action::CommandPaletteTypeChar(c) => {
if let Modal::CommandPalette(s) = &mut app.modal {
s.query.push(c);
s.selected = 0;
}
Ok(false)
}
Action::CommandPaletteBackspace => {
if let Modal::CommandPalette(s) = &mut app.modal {
s.query.pop();
s.selected = 0;
}
Ok(false)
}
Action::CommandPaletteNext => {
if let Modal::CommandPalette(s) = &mut app.modal {
let total = palette_filtered(&s.query).len();
if s.selected + 1 < total {
s.selected += 1;
}
}
Ok(false)
}
Action::CommandPalettePrev => {
if let Modal::CommandPalette(s) = &mut app.modal {
if s.selected > 0 {
s.selected -= 1;
}
}
Ok(false)
}
Action::CommandPaletteConfirm => {
let picked: Option<String> = if let Modal::CommandPalette(s) = &app.modal {
let filtered = palette_filtered(&s.query);
filtered.get(s.selected).map(|e| e.label.to_string())
} else {
None
};
app.modal = Modal::None;
if let Some(label) = picked {
return run_palette_action(&label, app).await;
}
Ok(false)
}
Action::MarkAllRead => {
let mut n = 0u32;
for r in &mut app.open_rooms {
if r.unread > 0 {
n = n.saturating_add(r.unread);
r.unread = 0;
}
}
app.set_status(if n == 0 {
"no unread to mark".to_string()
} else {
format!("marked {} message(s) read across {} room(s)", n, app.open_rooms.len())
});
Ok(false)
}
Action::HelpScrollUp => {
app.help_scroll = app.help_scroll.saturating_sub(1);
Ok(false)
}
Action::HelpScrollDown => {
app.help_scroll = app.help_scroll.saturating_add(1);
Ok(false)
}
Action::HelpPageUp => {
app.help_scroll = app.help_scroll.saturating_sub(10);
Ok(false)
}
Action::HelpPageDown => {
app.help_scroll = app.help_scroll.saturating_add(10);
Ok(false)
}
Action::UpdateCheckOptInYes => {
let _ = app.handle.set_update_check_enabled(true);
app.modal = Modal::None;
app.set_status("update check enabled — polling crates.io once per day");
spawn_update_check(app);
Ok(false)
}
Action::UpdateCheckOptInNo => {
let _ = app.handle.set_update_check_enabled(false);
app.modal = Modal::None;
app.set_status("update check disabled — toggle later in settings");
Ok(false)
}
Action::ToggleUpdateCheck => {
let cur = app.handle.update_check_enabled().unwrap_or(false);
let _ = app.handle.set_update_check_enabled(!cur);
app.set_status(if !cur {
"update check ON — polling crates.io once per day"
} else {
"update check OFF"
});
if !cur {
spawn_update_check(app);
} else {
app.update_banner = None;
}
Ok(false)
}
Action::DismissUpdateBanner => {
app.update_banner = None;
Ok(false)
}
Action::GenerateInvite => {
let our_peer = app.handle.peer_id().to_string();
let our_fp = app.handle.fingerprint().to_string();
let host_multiaddr = build_host_multiaddr(app, &our_peer);
let room = match app.active_room() {
Some(r) => {
if let Some(info) = app.handle.active_room_info(&r.room_id) {
let salt_b64 = info.passphrase_salt.as_ref().map(|s| {
base64::engine::general_purpose::STANDARD.encode(s)
});
Some(huddle_core::invite::InviteRoom {
id: info.id.clone(),
name: info.name.clone(),
encrypted: info.encrypted,
salt_b64,
creator_fingerprint: info.creator_fingerprint.clone(),
owner_fingerprints: app.handle.room_owners(&info.id),
})
} else {
None
}
}
_ => None,
};
let unsigned = huddle_core::invite::InviteLink {
v: 1,
host_multiaddr,
fingerprint: our_fp,
room: room.clone(),
creator_pubkey_b64: None,
signed_at_ms: 0,
signature_b64: None,
};
let invite = app.handle.sign_invite(unsigned.clone()).unwrap_or(unsigned);
match huddle_core::invite::encode(&invite) {
Ok(url) => {
app.modal = Modal::ShowInvite(ShowInviteState {
url,
includes_room: room.map(|r| r.name),
});
}
Err(e) => app.modal = Modal::Error(format!("encode invite: {e}")),
}
Ok(false)
}
Action::OpenPasteInvite => {
app.modal = Modal::PasteInvite(PasteInviteState { url: String::new() });
Ok(false)
}
Action::PasteInviteTypeChar(c) => {
if let Modal::PasteInvite(s) = &mut app.modal {
s.url.push(c);
}
Ok(false)
}
Action::PasteInviteBackspace => {
if let Modal::PasteInvite(s) = &mut app.modal {
s.url.pop();
}
Ok(false)
}
Action::PasteInviteConfirm => {
let url = match &app.modal {
Modal::PasteInvite(s) => s.url.clone(),
_ => return Ok(false),
};
match huddle_core::invite::decode(url.trim()) {
Ok(invite) => {
app.modal = Modal::ConfirmInvite(ConfirmInviteState { invite });
}
Err(e) => {
app.modal = Modal::Error(format!("bad invite link: {e}"));
}
}
Ok(false)
}
Action::ConfirmInviteAccept => {
let invite = match &app.modal {
Modal::ConfirmInvite(s) => s.invite.clone(),
_ => return Ok(false),
};
app.modal = Modal::None;
if !invite.host_multiaddr.trim().is_empty() {
match app
.handle
.dial_invite(&invite.host_multiaddr, &invite.fingerprint)
.await
{
Ok(()) => app.set_status(format!(
"dialing {} via invite…",
short_fp(&invite.fingerprint)
)),
Err(e) => {
app.set_status(format!("invite dial skipped: {e}"));
}
}
}
if let Some(room) = invite.room {
app.handle.seed_invite_room(&room);
if room.encrypted {
app.modal = Modal::JoinRoom(JoinRoomState {
room_id: room.id.clone(),
room_name: room.name.clone(),
encrypted: true,
passphrase: String::new(),
});
} else if let Err(e) = app.handle.join_room(&room.id, None).await {
app.modal = Modal::Error(format!("join failed: {e}"));
}
}
Ok(false)
}
Action::OpenGenerateJoinCode => {
let (room_id, room_name) = match app.active_room() {
Some(r) => (r.room_id.clone(), r.name.clone()),
None => return Ok(false),
};
match app.handle.generate_join_code(&room_id) {
Ok(code) => {
app.modal = Modal::ShowJoinCode(ShowJoinCodeState {
room_id,
room_name,
code,
});
}
Err(e) => app.set_status(format!("can't generate code: {e}")),
}
Ok(false)
}
Action::OpenJoinWithCode => {
let room = match &app.sidebar.selection {
SidebarItem::Group(id) | SidebarItem::Dm(id) => app
.handle
.discovered_rooms()
.into_iter()
.find(|d| d.room_id == *id),
_ => None,
};
let room = match room {
Some(r) => r,
None => {
app.set_status("select an encrypted group in the sidebar first");
return Ok(false);
}
};
if !room.encrypted {
app.set_status("code-join only applies to encrypted rooms");
return Ok(false);
}
app.modal = Modal::JoinWithCode(JoinWithCodeState {
room_id: room.room_id,
room_name: room.name,
code: String::new(),
});
Ok(false)
}
Action::JoinWithCodeTypeChar(c) => {
if let Modal::JoinWithCode(s) = &mut app.modal {
s.code.push(c);
}
Ok(false)
}
Action::JoinWithCodeBackspace => {
if let Modal::JoinWithCode(s) = &mut app.modal {
s.code.pop();
}
Ok(false)
}
Action::JoinWithCodeConfirm => {
let (room_id, code) = match &app.modal {
Modal::JoinWithCode(s) => (s.room_id.clone(), s.code.clone()),
_ => return Ok(false),
};
if code.trim().is_empty() {
return Ok(false);
}
app.modal = Modal::None;
if let Err(e) = app.handle.join_room_with_code(&room_id, code.trim()).await {
app.modal = Modal::Error(format!("code join failed: {e}"));
} else {
app.set_status("code submitted — waiting for owner (up to 30 s)");
}
Ok(false)
}
Action::SettingsTabNext => {
app.settings_tab = app.settings_tab.next();
Ok(false)
}
Action::SettingsTabPrev => {
app.settings_tab = app.settings_tab.prev();
Ok(false)
}
Action::SettingsTabSelect(tab) => {
app.settings_tab = tab;
Ok(false)
}
Action::SettingsToggleMdns => {
let now_on = app.handle.mdns_enabled();
if let Err(e) = app.handle.set_mdns_enabled(!now_on) {
app.modal = Modal::Error(format!("save failed: {e}"));
return Ok(false);
}
let new_state = !now_on;
app.set_status(if new_state {
"LAN discovery enabled — restart huddle to apply"
} else {
"LAN discovery disabled — restart huddle to apply"
});
Ok(false)
}
Action::SettingsToggleNotifications => {
let now_on = app.handle.notifications_enabled();
if let Err(e) = app.handle.set_notifications_enabled(!now_on) {
app.modal = Modal::Error(format!("save failed: {e}"));
return Ok(false);
}
app.set_status(if !now_on {
"desktop notifications on (OS-local only)"
} else {
"desktop notifications off"
});
Ok(false)
}
Action::ProfileFieldUp => {
if app.profile_cursor > 0 {
app.profile_cursor -= 1;
}
Ok(false)
}
Action::ProfileFieldDown => {
let max = profile_field_count(app).saturating_sub(1);
if app.profile_cursor < max {
app.profile_cursor += 1;
}
Ok(false)
}
Action::ProfileFieldYank => {
let (label, value) = match profile_field_at(app, app.profile_cursor) {
Some(v) => v,
None => return Ok(false),
};
match crate::clipboard::copy(&value) {
Ok(()) => app.set_status(format!("copied {} to clipboard", label)),
Err(e) => app.set_status(format!("copy failed: {}", e)),
}
Ok(false)
}
Action::OpenEditUsername => {
let current = app.handle.display_name().unwrap_or_default();
app.modal = Modal::EditUsername(EditUsernameState { input: current });
Ok(false)
}
Action::EditUsernameTypeChar(c) => {
if let Modal::EditUsername(s) = &mut app.modal {
if s.input.chars().count() < 32 {
s.input.push(c);
}
}
Ok(false)
}
Action::EditUsernameBackspace => {
if let Modal::EditUsername(s) = &mut app.modal {
s.input.pop();
}
Ok(false)
}
Action::EditUsernameConfirm => {
let input = match &app.modal {
Modal::EditUsername(s) => s.input.clone(),
_ => return Ok(false),
};
let trimmed = input.trim();
let new_name = if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
};
app.modal = Modal::None;
if let Err(e) = app.handle.set_username(new_name.as_deref()).await {
app.modal = Modal::Error(format!("set username failed: {e}"));
} else {
app.set_status(match &new_name {
Some(n) => format!("username set to {n}"),
None => "username cleared — you are now [anonymous]".into(),
});
}
Ok(false)
}
Action::OpenGoDarkModal => {
app.modal = Modal::GoDark(GoDarkState {
requires_passphrase: app.handle.has_master_passphrase(),
..GoDarkState::default()
});
Ok(false)
}
Action::OpenAddFriend => {
app.modal = Modal::AddFriend(AddFriendState::default());
Ok(false)
}
Action::AddFriendTypeChar(c) => {
if let Modal::AddFriend(s) = &mut app.modal {
if s.input.chars().count() < 64 {
s.input.push(c);
}
}
Ok(false)
}
Action::AddFriendBackspace => {
if let Modal::AddFriend(s) = &mut app.modal {
s.input.pop();
}
Ok(false)
}
Action::AddFriendConfirm => {
let input = match &app.modal {
Modal::AddFriend(s) => s.input.clone(),
_ => return Ok(false),
};
if input.trim().is_empty() {
return Ok(false);
}
app.modal = Modal::None;
let trimmed = input.trim();
if let Some(fp) = huddle_core::app::normalize_to_fingerprint(trimmed) {
if fp.as_str() == app.handle.fingerprint() {
app.modal = Modal::Error("that's your own HD-ID".into());
return Ok(false);
}
match app.handle.send_contact_request(&fp, None).await {
Ok(()) => {
app.set_status(format!(
"contact request sent to HD-{} — opens a DM when they accept",
short_fp(&fp).to_uppercase()
));
}
Err(e) => {
app.modal = Modal::Error(format!("add contact: {e}"));
return Ok(false);
}
}
let _ = app.handle.dial_by_id_or_username(trimmed).await;
} else {
match app.handle.dial_by_id_or_username(trimmed).await {
Ok(()) => {
app.set_status(format!("dialing {} (racing LAN / IP / relay)…", trimmed));
}
Err(e) => {
app.modal = Modal::Error(format!("add friend: {e}"));
}
}
}
Ok(false)
}
Action::GoDarkTypeChar(c) => {
if let Modal::GoDark(s) = &mut app.modal {
if s.input.chars().count() < 128 {
s.input.push(c);
}
}
Ok(false)
}
Action::GoDarkBackspace => {
if let Modal::GoDark(s) = &mut app.modal {
s.input.pop();
}
Ok(false)
}
Action::GoDarkConfirm => {
let (input, requires_passphrase) = match &app.modal {
Modal::GoDark(s) => (s.input.clone(), s.requires_passphrase),
_ => return Ok(false),
};
if !requires_passphrase && input != GO_DARK_CONFIRM_PHRASE {
if let Modal::GoDark(s) = &mut app.modal {
s.last_error = Some(format!(
"type `{}` exactly to confirm",
GO_DARK_CONFIRM_PHRASE
));
s.input.clear();
}
return Ok(false);
}
let passphrase_to_send = if requires_passphrase {
input
} else {
String::new()
};
match app.handle.go_dark(&passphrase_to_send).await {
Ok(()) => {
Ok(false)
}
Err(e) => {
if let Modal::GoDark(s) = &mut app.modal {
s.last_error = Some(format!("{e}"));
s.input.clear();
}
Ok(false)
}
}
}
Action::SettingsToggleGlobalVerifiedOnly => {
let new_state = !app.handle.verified_only_inbound();
if let Err(e) = app.handle.set_verified_only_inbound(new_state) {
app.modal = Modal::Error(format!("save failed: {e}"));
return Ok(false);
}
Ok(false)
}
Action::ClearBlockedPeers => {
let blocked = app.handle.list_blocked_peers();
let n = blocked.len();
let mut errors = 0usize;
for fp in blocked {
if app.handle.unblock_peer(&fp).is_err() {
errors += 1;
}
}
let msg = if errors == 0 {
format!("cleared {} blocked peer(s)", n)
} else {
format!(
"cleared {} blocked peer(s); {} error(s)",
n.saturating_sub(errors),
errors
)
};
app.set_status(msg);
app.modal = Modal::None;
Ok(false)
}
Action::ToggleRoomVerifiedOnly => {
let room_id = match app.active_room() {
Some(r) => r.room_id.clone(),
None => return Ok(false),
};
let our_fp = app.handle.fingerprint().to_string();
if !app.handle.is_owner(&room_id, &our_fp) {
app.set_status("only an owner can toggle room verified-only");
return Ok(false);
}
let new_state = !app.handle.room_verified_only(&room_id);
if let Err(e) = app.handle.set_room_verified_only(&room_id, new_state) {
app.modal = Modal::Error(format!("toggle failed: {e}"));
return Ok(false);
}
app.set_status(if new_state {
"room is now verified-only — non-SAS-verified joiners refused"
} else {
"room verified-only mode off"
});
Ok(false)
}
Action::VerifyStartSas => {
let (room_id, partner_fp) = match &app.modal {
Modal::Verify(v) => match v.members.get(v.selected) {
Some((fp, _)) => (v.room_id.clone(), fp.clone()),
None => return Ok(false),
},
_ => return Ok(false),
};
match app.handle.sas_start(&room_id, &partner_fp).await {
Ok(tx_id) => {
app.modal = Modal::Sas(SasState {
room_id,
partner_fingerprint: partner_fp,
tx_id,
stage: SasStage::Waiting,
});
}
Err(e) => app.modal = Modal::Error(format!("SAS start failed: {e}")),
}
Ok(false)
}
Action::SasMatch => {
if let Modal::Sas(s) = &mut app.modal {
let tx_id = s.tx_id.clone();
if let SasStage::Comparing {
ref mut our_matched,
..
} = s.stage
{
*our_matched = true;
}
if let Err(e) = app.handle.sas_match(&tx_id).await {
app.modal = Modal::Error(format!("SAS match failed: {e}"));
return Ok(false);
}
app.set_status("SAS match sent — waiting for partner to confirm");
}
Ok(false)
}
Action::SasCancel => {
if let Modal::Sas(s) = &app.modal {
app.handle.sas_cancel(&s.tx_id);
}
app.modal = Modal::None;
Ok(false)
}
Action::OpenKickPicker => {
let (room_id, members) = match owner_action_members(app, MemberActionKind::Kick) {
Some(t) => t,
None => return Ok(false),
};
app.modal = Modal::MemberAction(MemberActionState {
room_id,
kind: MemberActionKind::Kick,
members,
selected: 0,
});
Ok(false)
}
Action::OpenGrantPicker => {
let (room_id, members) = match owner_action_members(app, MemberActionKind::Grant) {
Some(t) => t,
None => return Ok(false),
};
app.modal = Modal::MemberAction(MemberActionState {
room_id,
kind: MemberActionKind::Grant,
members,
selected: 0,
});
Ok(false)
}
Action::ShowRoomBans => {
let room_id = match app.active_room() {
Some(r) => r.room_id.clone(),
None => return Ok(false),
};
if !app.handle.we_are_owner(&room_id) {
return Ok(false);
}
let bans = app.handle.list_room_bans(&room_id);
let body = if bans.is_empty() {
"no bans in this room.".to_string()
} else {
let mut s = format!("{} ban(s) in this room:\n\n", bans.len());
for fp in &bans {
s.push_str(&format!(" {}\n", short_fp(fp)));
}
s.push_str("\nban is enforced by key rotation (the banned peer can no longer derive the room key). press any key to dismiss.");
s
};
app.replace_modal_if_idle(Modal::Info(body));
Ok(false)
}
Action::MemberActionNext => {
if let Modal::MemberAction(s) = &mut app.modal {
if s.selected + 1 < s.members.len() {
s.selected += 1;
}
}
Ok(false)
}
Action::MemberActionPrev => {
if let Modal::MemberAction(s) = &mut app.modal {
if s.selected > 0 {
s.selected -= 1;
}
}
Ok(false)
}
Action::MemberActionConfirm => {
let snapshot = if let Modal::MemberAction(s) = &app.modal {
s.members
.get(s.selected)
.map(|(fp, _)| (s.room_id.clone(), s.kind, fp.clone()))
} else {
None
};
let (room_id, kind, target_fp) = match snapshot {
Some(t) => t,
None => return Ok(false),
};
app.modal = Modal::None;
match kind {
MemberActionKind::Grant => {
match app.handle.grant_owner(&room_id, &target_fp).await {
Ok(()) => app.set_status(format!("granted owner to {}", short_fp(&target_fp))),
Err(e) => app.modal = Modal::Error(format!("grant failed: {e}")),
}
}
MemberActionKind::Kick => {
match app.handle.kick_member(&room_id, &target_fp).await {
Ok(new_pp) if new_pp.is_empty() => {
app.set_status(format!("kicked {}", short_fp(&target_fp)));
}
Ok(new_pp) => {
app.modal = Modal::Info(format!(
"kicked {}. new passphrase (share OOB with remaining members):\n\n {}",
short_fp(&target_fp),
new_pp
));
}
Err(e) => app.modal = Modal::Error(format!("kick failed: {e}")),
}
}
}
Ok(false)
}
Action::InboundDialAccept => {
if let Modal::InboundDial(s) = app.modal.clone() {
app.handle.accept_inbound(s.peer_id, &s.address).await;
app.set_status(format!("connected to {}", short_fp(&s.fingerprint)));
app.modal = Modal::None;
app.refresh_known_peers();
}
Ok(false)
}
Action::InboundDialReject => {
if let Modal::InboundDial(s) = app.modal.clone() {
if let Err(e) = app.handle.reject_inbound(s.peer_id, &s.fingerprint).await {
app.modal = Modal::Error(format!("reject failed: {e}"));
return Ok(false);
}
app.set_status(format!("rejected {}", short_fp(&s.fingerprint)));
app.modal = Modal::None;
}
Ok(false)
}
Action::InboundDialTrust => {
if let Modal::InboundDial(s) = app.modal.clone() {
if let Err(e) = app
.handle
.trust_inbound(s.peer_id, &s.fingerprint, &s.address)
.await
{
app.modal = Modal::Error(format!("trust failed: {e}"));
return Ok(false);
}
app.set_status(format!("trusted {} — won't ask again", short_fp(&s.fingerprint)));
app.modal = Modal::None;
app.refresh_known_peers();
}
Ok(false)
}
Action::AttachPickerConfirm => {
let pick: Option<std::path::PathBuf> = match &mut app.modal {
Modal::AttachPicker(s) => match s.flat.get(s.selected) {
Some(r) if r.is_dir => {
s.toggle_expand();
None
}
Some(_) => s.selected_path(),
None => None,
},
_ => None,
};
if let Some(path) = pick {
let room_id = match app.active_room() {
Some(r) => r.room_id.clone(),
None => return Ok(false),
};
app.modal = Modal::None;
match app.handle.send_file(&room_id, &path).await {
Ok(file_id) => {
app.set_status(format!("sending {} ({})", path.display(), &file_id[..12]));
}
Err(e) => {
app.modal = Modal::Error(format!("send failed: {e}"));
}
}
}
Ok(false)
}
Action::SidebarSectionPrev => {
sidebar_jump_section(app, -1);
Ok(false)
}
Action::SidebarToggleExpand => {
sidebar_toggle_expand(app);
Ok(false)
}
Action::JumpToPeoplePane => {
app.pane = Pane::People;
app.sidebar.selection = SidebarItem::Section(SidebarSection::People);
app.refresh_pending_requests();
app.refresh_pending_contact_requests();
if !app.pending_contact_requests.is_empty() {
app.people_focus = PeopleFocus::ContactRequests;
app.selected_contact_request_idx = 0;
} else if !app.pending_requests.is_empty() {
app.people_focus = PeopleFocus::Pending;
app.selected_pending_idx = 0;
}
Ok(false)
}
Action::JumpToSettingsPane => {
app.pane = Pane::Settings;
app.settings_tab = SettingsTab::Account;
app.sidebar.selection = SidebarItem::Section(SidebarSection::Settings);
Ok(false)
}
Action::OpenComposeDm => {
app.modal = Modal::ComposeDm(ComposeDmState::default());
Ok(false)
}
Action::ComposeDmTypeChar(c) => {
if let Modal::ComposeDm(s) = &mut app.modal {
s.input.push(c);
}
Ok(false)
}
Action::ComposeDmBackspace => {
if let Modal::ComposeDm(s) = &mut app.modal {
s.input.pop();
}
Ok(false)
}
Action::ComposeDmCancel => {
app.modal = Modal::None;
Ok(false)
}
Action::ComposeDmConfirm => {
let input = match &app.modal {
Modal::ComposeDm(s) => s.input.trim().to_string(),
_ => return Ok(false),
};
if input.is_empty() {
return Ok(false);
}
let resolved = resolve_dm_target(app, &input);
match resolved {
Some(fp) => {
app.modal = Modal::None;
match app.handle.start_direct(&fp).await {
Ok(room_id) => {
app.switch_to_room(&room_id);
app.set_status(format!("DM with {}", short_fp(&fp)));
}
Err(e) => app.modal = Modal::Error(format!("DM failed: {e}")),
}
}
None => {
app.modal = Modal::AddFriend(AddFriendState { input });
}
}
Ok(false)
}
Action::ToggleMemberMargin => {
app.show_member_margin = !app.show_member_margin;
Ok(false)
}
Action::OpenInvitePicker => {
let (room_id, room_name, is_group) = match app.active_room() {
Some(r) => {
let info = app.handle.active_room_info(&r.room_id);
let is_group = info
.as_ref()
.map(|i| i.kind != huddle_core::storage::repo::RoomKind::Direct)
.unwrap_or(false);
(
r.room_id.clone(),
info.map(|i| i.name).unwrap_or_default(),
is_group,
)
}
None => {
app.set_status("open a group first — `Shift+I` for OOB or `s` to create");
return Ok(false);
}
};
if !is_group {
app.set_status("DMs are 1-1 by design — open a group to invite peers");
return Ok(false);
}
let candidates = gather_invite_candidates(app, &room_id);
if candidates.is_empty() {
app.set_status(
"no peers to invite yet — verify someone with Ctrl+V or share `Shift+I` OOB",
);
return Ok(false);
}
app.modal = Modal::InvitePicker(InvitePickerState {
room_id,
room_name,
candidates,
selected: HashSet::new(),
filter: String::new(),
cursor: 0,
status_line: None,
});
Ok(false)
}
Action::InvitePickerCancel => {
app.modal = Modal::None;
Ok(false)
}
Action::InvitePickerFilterTypeChar(c) => {
if let Modal::InvitePicker(s) = &mut app.modal {
s.filter.push(c);
s.cursor = 0;
s.status_line = None;
}
Ok(false)
}
Action::InvitePickerFilterBackspace => {
if let Modal::InvitePicker(s) = &mut app.modal {
s.filter.pop();
s.cursor = 0;
s.status_line = None;
}
Ok(false)
}
Action::InvitePickerCursorUp => {
if let Modal::InvitePicker(s) = &mut app.modal {
if s.cursor > 0 {
s.cursor -= 1;
}
s.status_line = None;
}
Ok(false)
}
Action::InvitePickerCursorDown => {
if let Modal::InvitePicker(s) = &mut app.modal {
let visible_len = filtered_invite_candidates(s).len();
if s.cursor + 1 < visible_len {
s.cursor += 1;
}
s.status_line = None;
}
Ok(false)
}
Action::InvitePickerToggleSelected => {
if let Modal::InvitePicker(s) = &mut app.modal {
let visible = filtered_invite_candidates(s);
if let Some(c) = visible.get(s.cursor).cloned() {
if s.selected.contains(&c.fingerprint) {
s.selected.remove(&c.fingerprint);
s.status_line = None;
} else if s.selected.len() >= INVITE_PICKER_SOFT_CAP {
s.status_line = Some(format!(
"selection cap: {} max per send",
INVITE_PICKER_SOFT_CAP
));
} else {
s.selected.insert(c.fingerprint);
s.status_line = None;
}
}
}
Ok(false)
}
Action::InvitePickerSend => {
let (room_id, selected_fps) = match &app.modal {
Modal::InvitePicker(s) => {
if s.selected.is_empty() {
if let Modal::InvitePicker(s2) = &mut app.modal {
s2.status_line = Some(
"Space to select peers · Enter sends · Esc cancels".into(),
);
}
return Ok(false);
}
(s.room_id.clone(), s.selected.iter().cloned().collect::<Vec<_>>())
}
_ => return Ok(false),
};
let invite_text = match build_room_invite_link(app, &room_id) {
Ok(t) => t,
Err(e) => {
if let Modal::InvitePicker(s) = &mut app.modal {
s.status_line = Some(format!("invite build failed: {e}"));
}
return Ok(false);
}
};
let mut sent = 0usize;
let mut failures: Vec<String> = Vec::new();
for fp in &selected_fps {
let dm_room_id = match app.handle.start_direct(fp).await {
Ok(rid) => rid,
Err(e) => {
failures.push(format!("{}: {}", short_fp(fp), e));
continue;
}
};
match app.handle.send_room_message(&dm_room_id, &invite_text).await {
Ok(()) => sent += 1,
Err(e) => failures.push(format!("{}: {}", short_fp(fp), e)),
}
}
app.modal = Modal::None;
if failures.is_empty() {
app.set_status(format!("sent invite to {} peer(s)", sent));
} else {
app.set_status(format!(
"sent to {}; failed for {}",
sent,
failures.len()
));
tracing::warn!(?failures, "invite-picker send had partial failures");
}
Ok(false)
}
Action::PeopleFocusNext => {
app.refresh_pending_requests();
app.refresh_pending_contact_requests();
let has_pending = !app.pending_requests.is_empty();
let has_contact_reqs = !app.pending_contact_requests.is_empty();
app.people_focus = match app.people_focus {
PeopleFocus::ContactRequests => {
if has_pending {
PeopleFocus::Pending
} else {
PeopleFocus::Known
}
}
PeopleFocus::Pending => PeopleFocus::Known,
PeopleFocus::Known => PeopleFocus::Verified,
PeopleFocus::Verified => PeopleFocus::Blocked,
PeopleFocus::Blocked => {
if has_contact_reqs {
PeopleFocus::ContactRequests
} else if has_pending {
PeopleFocus::Pending
} else {
PeopleFocus::Known
}
}
};
Ok(false)
}
Action::PendingRequestUp => {
if app.selected_pending_idx > 0 {
app.selected_pending_idx -= 1;
}
Ok(false)
}
Action::PendingRequestDown => {
if app.selected_pending_idx + 1 < app.pending_requests.len() {
app.selected_pending_idx += 1;
}
Ok(false)
}
Action::PendingRequestAccept => {
let fp = app
.pending_requests
.get(app.selected_pending_idx)
.map(|r| r.fingerprint.clone());
if let Some(fp) = fp {
match app.handle.accept_pending_friend_request(&fp).await {
Ok(()) => {
app.set_status(format!("re-dialing {} …", short_fp(&fp)));
}
Err(e) => {
app.modal = Modal::Error(format!("accept failed: {e}"));
}
}
app.refresh_pending_requests();
if app.pending_requests.is_empty() {
app.people_focus = PeopleFocus::Known;
}
}
Ok(false)
}
Action::PendingRequestReject => {
let fp = app
.pending_requests
.get(app.selected_pending_idx)
.map(|r| r.fingerprint.clone());
if let Some(fp) = fp {
if let Err(e) = app.handle.reject_pending_friend_request(&fp) {
app.modal = Modal::Error(format!("reject failed: {e}"));
} else {
app.set_status(format!("rejected + blocked {}", short_fp(&fp)));
}
app.refresh_pending_requests();
if app.pending_requests.is_empty() {
app.people_focus = PeopleFocus::Known;
}
}
Ok(false)
}
Action::ContactRequestUp => {
if app.selected_contact_request_idx > 0 {
app.selected_contact_request_idx -= 1;
}
Ok(false)
}
Action::ContactRequestDown => {
if app.selected_contact_request_idx + 1 < app.pending_contact_requests.len() {
app.selected_contact_request_idx += 1;
}
Ok(false)
}
Action::ContactRequestAccept => {
let fp = app
.pending_contact_requests
.get(app.selected_contact_request_idx)
.map(|r| r.fingerprint.clone());
if let Some(fp) = fp {
match app.handle.accept_contact_request(&fp).await {
Ok(()) => {
app.set_status(format!("accepted — opening DM with {}", short_fp(&fp)));
}
Err(e) => {
app.modal = Modal::Error(format!("accept failed: {e}"));
}
}
app.refresh_pending_contact_requests();
if app.selected_contact_request_idx >= app.pending_contact_requests.len() {
app.selected_contact_request_idx =
app.pending_contact_requests.len().saturating_sub(1);
}
if app.pending_contact_requests.is_empty() {
app.people_focus = PeopleFocus::Known;
}
}
Ok(false)
}
Action::ContactRequestReject => {
let fp = app
.pending_contact_requests
.get(app.selected_contact_request_idx)
.map(|r| r.fingerprint.clone());
if let Some(fp) = fp {
if let Err(e) = app.handle.reject_contact_request(&fp, false) {
app.modal = Modal::Error(format!("decline failed: {e}"));
} else {
app.set_status(format!("declined {}", short_fp(&fp)));
}
app.refresh_pending_contact_requests();
if app.selected_contact_request_idx >= app.pending_contact_requests.len() {
app.selected_contact_request_idx =
app.pending_contact_requests.len().saturating_sub(1);
}
if app.pending_contact_requests.is_empty() {
app.people_focus = PeopleFocus::Known;
}
}
Ok(false)
}
Action::PeoplePersonReconnect => {
if let Some(p) = app.known_peers.get(app.selected_known_idx).cloned() {
if let Err(e) = app.handle.redial(&p.address).await {
app.modal = Modal::Error(format!("dial failed: {e}"));
}
}
Ok(false)
}
Action::PeoplePersonBlock => {
if let Some(p) = app.known_peers.get(app.selected_known_idx).cloned() {
if let Some(fp) = p.fingerprint.as_deref() {
let _ = app.handle.block_peer(fp);
app.set_status(format!("blocked {}", short_fp(fp)));
} else {
app.set_status("can't block — fingerprint not learned yet (try after Identify)");
}
}
Ok(false)
}
Action::PeoplePersonUnblock => {
let blocked = app.handle.list_blocked_peers();
if let Some(fp) = blocked.get(app.selected_blocked_idx).cloned() {
let _ = app.handle.unblock_peer(&fp);
app.set_status(format!("unblocked {}", short_fp(&fp)));
}
Ok(false)
}
Action::PeoplePersonForget => {
if let Some(p) = app.known_peers.get(app.selected_known_idx).cloned() {
let _ = app.handle.forget_peer(&p.address).await;
app.refresh_known_peers();
}
Ok(false)
}
Action::PeoplePersonStartDm => {
if let Some(p) = app.known_peers.get(app.selected_known_idx).cloned() {
if let Some(fp) = p.fingerprint.clone() {
match app.handle.start_direct(&fp).await {
Ok(rid) => app.switch_to_room(&rid),
Err(e) => app.modal = Modal::Error(format!("DM failed: {e}")),
}
} else {
app.set_status("can't DM yet — peer hasn't identified (try after they connect)");
}
}
Ok(false)
}
}
}
fn resolve_dm_target(app: &TuiApp, input: &str) -> Option<String> {
let trimmed = input.trim();
if let Some(fp) = huddle_core::app::normalize_to_fingerprint(trimmed) {
return Some(fp);
}
let matches = app.handle.peers_with_username(trimmed);
if matches.len() == 1 {
return matches.into_iter().next();
}
None
}
fn focused_card_info(
app: &TuiApp,
) -> Option<(String, String, huddle_core::storage::repo::AttachmentStatus, bool)> {
let r = app.active_room()?;
let a = r.attachments.get(r.focused_card_idx)?;
Some((r.room_id.clone(), a.file_id.clone(), a.status, a.encrypted))
}
pub fn gather_invite_candidates(
app: &TuiApp,
room_id: &str,
) -> Vec<InviteCandidate> {
let our_fp = app.handle.fingerprint().to_string();
let in_room: HashSet<String> = app.handle.room_members(room_id).into_iter().collect();
let blocked: HashSet<String> = app.handle.list_blocked_peers().into_iter().collect();
let mut out: Vec<InviteCandidate> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
let push = |list: &mut Vec<InviteCandidate>,
seen: &mut HashSet<String>,
fp: String,
tier: InviteTier,
username: Option<String>| {
if seen.insert(fp.clone()) {
list.push(InviteCandidate {
fingerprint: fp,
username,
tier,
});
}
};
for fp in app.handle.list_verified_peers() {
if fp == our_fp || in_room.contains(&fp) || blocked.contains(&fp) {
continue;
}
let name = app.handle.lookup_username(&fp);
push(&mut out, &mut seen, fp, InviteTier::Verified, name);
}
for rid in app.handle.active_room_ids() {
let info = match app.handle.active_room_info(&rid) {
Some(i) => i,
None => continue,
};
if info.kind != huddle_core::storage::repo::RoomKind::Direct {
continue;
}
if let Some(partner) = app.handle.dm_partner_fingerprint(&rid) {
if partner == our_fp
|| in_room.contains(&partner)
|| blocked.contains(&partner)
{
continue;
}
let name = app.handle.lookup_username(&partner);
push(&mut out, &mut seen, partner, InviteTier::DmPartner, name);
}
}
for p in &app.known_peers {
if let Some(fp) = &p.fingerprint {
if fp == &our_fp || in_room.contains(fp) || blocked.contains(fp) {
continue;
}
let name = app.handle.lookup_username(fp);
push(&mut out, &mut seen, fp.clone(), InviteTier::Known, name);
}
}
out
}
pub fn filtered_invite_candidates(state: &InvitePickerState) -> Vec<InviteCandidate> {
if state.filter.is_empty() {
return state.candidates.clone();
}
let needle = state.filter.to_lowercase();
state
.candidates
.iter()
.filter(|c| {
if let Some(u) = &c.username {
if u.to_lowercase().contains(&needle) {
return true;
}
}
let short = crate::ui::short_fp(&c.fingerprint).to_lowercase();
short.starts_with(&needle) || needle.starts_with("hd-") && {
let after = needle.trim_start_matches("hd-").to_lowercase();
short.starts_with(&after)
}
})
.cloned()
.collect()
}
fn pick_invite_host_addr(app: &TuiApp) -> Option<String> {
if let Some(a) = app.handle.dialable_addrs().into_iter().next() {
return Some(a);
}
if let Some(a) = app
.listen_addresses
.iter()
.find(|a| !is_unspecified_or_loopback(a))
{
return Some(a.clone());
}
app.listen_addresses.first().cloned()
}
fn is_unspecified_or_loopback(addr: &str) -> bool {
addr.contains("/ip4/127.")
|| addr.contains("/ip4/0.0.0.0")
|| addr.contains("/ip6/::1/")
|| addr.contains("/ip6/::/")
}
fn build_host_multiaddr(app: &TuiApp, our_peer: &str) -> String {
if !app.libp2p_active() {
return String::new();
}
match pick_invite_host_addr(app) {
Some(listen) if listen.contains(our_peer) => listen,
Some(listen) => format!("{}/p2p/{}", listen, our_peer),
None => String::new(),
}
}
pub fn build_room_invite_link(app: &TuiApp, room_id: &str) -> anyhow::Result<String> {
use anyhow::anyhow;
let our_peer = app.handle.peer_id().to_string();
let our_fp = app.handle.fingerprint().to_string();
let host_multiaddr = build_host_multiaddr(app, &our_peer);
let info = app
.handle
.active_room_info(room_id)
.ok_or_else(|| anyhow!("room not active locally"))?;
let salt_b64 = info.passphrase_salt.as_ref().map(|s| {
base64::engine::general_purpose::STANDARD.encode(s)
});
let room = huddle_core::invite::InviteRoom {
id: info.id,
name: info.name,
encrypted: info.encrypted,
salt_b64,
creator_fingerprint: info.creator_fingerprint,
owner_fingerprints: app.handle.room_owners(room_id),
};
let unsigned = huddle_core::invite::InviteLink {
v: 1,
host_multiaddr,
fingerprint: our_fp,
room: Some(room),
creator_pubkey_b64: None,
signed_at_ms: 0,
signature_b64: None,
};
let invite = app
.handle
.sign_invite(unsigned.clone())
.unwrap_or(unsigned);
huddle_core::invite::encode(&invite).map_err(|e| anyhow!("encode failed: {e}"))
}
#[derive(Debug, Clone, Copy)]
pub struct PaletteEntry {
pub label: &'static str,
pub keys: &'static str,
}
const EXTRA_PALETTE_ENTRIES: &[PaletteEntry] = &[
PaletteEntry {
label: "toggle update check (crates.io)",
keys: "",
},
PaletteEntry {
label: "dismiss update banner",
keys: "",
},
PaletteEntry {
label: "clear notification history",
keys: "Ctrl+H · c",
},
PaletteEntry {
label: "invite peers to room…",
keys: "Alt+I",
},
];
pub fn palette_filtered(query: &str) -> Vec<PaletteEntry> {
use crate::keybindings::palette_entries;
let entries: Vec<PaletteEntry> = palette_entries()
.map(|(label, keys)| PaletteEntry { label, keys })
.chain(EXTRA_PALETTE_ENTRIES.iter().copied())
.collect();
if query.trim().is_empty() {
return entries;
}
let q_lower: String = query.to_lowercase();
entries
.into_iter()
.filter(|e| fuzzy_match(&e.label.to_lowercase(), &q_lower))
.collect()
}
fn fuzzy_match(haystack: &str, needle: &str) -> bool {
let mut it = haystack.chars();
'outer: for nc in needle.chars() {
for hc in it.by_ref() {
if hc == nc {
continue 'outer;
}
}
return false;
}
true
}
pub async fn run_palette_action(label: &str, app: &mut TuiApp) -> Result<bool> {
match label {
"start a new room" => {
app.modal = Modal::StartRoom(StartRoomState::new());
}
"add friend by HD ID or username" => {
app.modal = Modal::AddFriend(AddFriendState::default());
}
"dial peer by address" => {
app.modal = Modal::DialPeer(DialPeerState::default());
}
"show your QR identity" => {
app.modal = Modal::QrIdentity;
}
"open settings" => {
app.pane = Pane::Settings;
app.settings_tab = SettingsTab::Account;
app.sidebar.selection = SidebarItem::Section(SidebarSection::Settings);
}
"join with code" => {
app.set_status("select an encrypted room in the lobby first, then press c");
}
"generate invite link" | "generate invite for this room" => {
return Box::pin(handle_action(Action::GenerateInvite, app)).await;
}
"paste invite link" => {
app.modal = Modal::PasteInvite(PasteInviteState { url: String::new() });
}
"mark all rooms read" => {
return Box::pin(handle_action(Action::MarkAllRead, app)).await;
}
"refresh rooms" => {
app.refresh_discovered();
app.refresh_known_peers();
app.set_status("refreshed");
}
"show help" => {
app.help_scroll = 0;
app.modal = Modal::Help;
}
"show what's new / onboarding" => {
let pages: Vec<usize> = (0..ONBOARDING_PAGES.len()).collect();
app.modal = Modal::Onboarding { pages, cursor: 0 };
}
"show notification history" => {
app.modal = Modal::StatusHistory { scroll: 0 };
}
"quit huddle" => {
app.modal = Modal::QuitConfirm;
}
"switch to next room" => {
return Box::pin(handle_action(Action::TabNext, app)).await;
}
"leave current room" => {
return Box::pin(handle_action(Action::LeaveRoom, app)).await;
}
"back to lobby" => {
app.pane = Pane::Welcome;
app.sidebar.focus = SidebarFocus::Sidebar;
}
"search room history" => {
return Box::pin(handle_action(Action::OpenSearch, app)).await;
}
"verify members" => {
return Box::pin(handle_action(Action::OpenVerify, app)).await;
}
"rotate room key" => {
return Box::pin(handle_action(Action::OpenRotateRoom, app)).await;
}
"attach a file" => {
return Box::pin(handle_action(Action::OpenAttachmentPicker, app)).await;
}
"toggle room mute" => {
return Box::pin(handle_action(Action::ToggleMute, app)).await;
}
"kick member" => {
return Box::pin(handle_action(Action::OpenKickPicker, app)).await;
}
"grant owner" => {
return Box::pin(handle_action(Action::OpenGrantPicker, app)).await;
}
"toggle verified-only joins" => {
return Box::pin(handle_action(Action::ToggleRoomVerifiedOnly, app)).await;
}
"generate join code" => {
return Box::pin(handle_action(Action::OpenGenerateJoinCode, app)).await;
}
"show room bans" => {
return Box::pin(handle_action(Action::ShowRoomBans, app)).await;
}
"toggle update check (crates.io)" => {
return Box::pin(handle_action(Action::ToggleUpdateCheck, app)).await;
}
"dismiss update banner" => {
return Box::pin(handle_action(Action::DismissUpdateBanner, app)).await;
}
"clear notification history" => {
return Box::pin(handle_action(Action::ClearStatusHistory, app)).await;
}
"invite peers to room…" => {
return Box::pin(handle_action(Action::OpenInvitePicker, app)).await;
}
other => {
app.set_status(format!("no dispatch for '{}'", other));
}
}
Ok(false)
}
const UPDATE_CHECK_INTERVAL_SECS: i64 = 24 * 60 * 60;
pub fn spawn_update_check(app: &TuiApp) {
let handle = app.handle.clone();
let slot = app.update_check_slot.clone();
tokio::spawn(async move {
let result = tokio::task::spawn_blocking(move || run_update_check(&handle))
.await
.ok()
.flatten();
if let Some(v) = result {
if let Ok(mut s) = slot.lock() {
*s = Some(v);
}
}
});
}
fn run_update_check(handle: &huddle_core::app::AppHandle) -> Option<String> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_secs() as i64;
let cached_at = handle.last_update_check_at();
let cached_version = handle.last_known_remote_version();
if now - cached_at < UPDATE_CHECK_INTERVAL_SECS {
return cached_version.filter(|v| is_version_newer(v, env!("CARGO_PKG_VERSION")));
}
let body = ureq::get("https://crates.io/api/v1/crates/huddle")
.set("User-Agent", &format!("huddle/{}", env!("CARGO_PKG_VERSION")))
.timeout(std::time::Duration::from_secs(10))
.call()
.ok()?
.into_string()
.ok()?;
let version = parse_max_stable_version(&body)?;
let _ = handle.set_last_update_check_at(now);
let _ = handle.set_last_known_remote_version(&version);
if is_version_newer(&version, env!("CARGO_PKG_VERSION")) {
Some(version)
} else {
None
}
}
fn parse_max_stable_version(body: &str) -> Option<String> {
let needle = "\"max_stable_version\":\"";
let start = body.find(needle)? + needle.len();
let rest = &body[start..];
let end = rest.find('"')?;
let v = &rest[..end];
if v.is_empty() || !v.chars().next().is_some_and(|c| c.is_ascii_digit()) {
return None;
}
Some(v.to_string())
}
fn is_version_newer(remote: &str, current: &str) -> bool {
parse_semver(remote) > parse_semver(current)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_unspecified_or_loopback_filters_useless_addrs() {
assert!(is_unspecified_or_loopback("/ip4/127.0.0.1/tcp/9000"));
assert!(is_unspecified_or_loopback("/ip4/0.0.0.0/tcp/9000"));
assert!(is_unspecified_or_loopback("/ip6/::1/tcp/9000"));
assert!(is_unspecified_or_loopback("/ip6/::/tcp/9000"));
}
#[test]
fn is_unspecified_or_loopback_passes_routable_addrs() {
assert!(!is_unspecified_or_loopback("/ip4/192.168.1.5/tcp/9000"));
assert!(!is_unspecified_or_loopback("/ip4/10.0.0.5/tcp/9000"));
assert!(!is_unspecified_or_loopback("/ip4/8.8.8.8/tcp/9000"));
assert!(!is_unspecified_or_loopback("/ip6/2001:db8::1/tcp/9000"));
assert!(!is_unspecified_or_loopback(
"/ip4/1.2.3.4/tcp/4001/p2p/12D3KooRelay/p2p-circuit"
));
}
}