2026-10-01 23:12:21 +03:00
|
|
|
//! The view: a compact horizontal split — a tab bar, the full-width list
|
|
|
|
|
//! above, the article below, and a status line at the bottom edge. Protocol
|
|
|
|
|
//! values (addresses, keys, ids, dates) are monospace; prose stays sans.
|
|
|
|
|
|
|
|
|
|
use iced::widget::text::Wrapping;
|
|
|
|
|
use iced::widget::{
|
|
|
|
|
button, column, container, qr_code, row, rule, scrollable, text, text_editor, text_input, Space,
|
|
|
|
|
};
|
|
|
|
|
use iced::{Alignment, Element, Font, Length, Padding};
|
|
|
|
|
|
|
|
|
|
use fumi_core::crypto::{b32, fingerprint};
|
|
|
|
|
|
|
|
|
|
use crate::app::{Carrier, Dialog, Message, OnboardMode, Page};
|
|
|
|
|
use crate::ops::{self, Contact, Folder, Row};
|
|
|
|
|
use crate::theme::{self, ACCENT, MUTED, TEXT};
|
|
|
|
|
|
2026-10-02 00:08:48 +03:00
|
|
|
/// Text sizes: rows and controls read at 14, the article body at 15, the
|
|
|
|
|
/// selected subject as a 16 heading — the sizes of the notational-velocity
|
|
|
|
|
/// family this layout borrows from.
|
|
|
|
|
const S: f32 = 14.0;
|
|
|
|
|
const BODY: f32 = 15.0;
|
|
|
|
|
const BIG: f32 = 16.0;
|
|
|
|
|
|
|
|
|
|
/// Column widths of the contacts table, in pixels; monospace keeps them
|
|
|
|
|
/// aligned.
|
|
|
|
|
const ADDR_W: f32 = 300.0;
|
|
|
|
|
const KEY_W: f32 = 300.0;
|
|
|
|
|
const STATE_W: f32 = 110.0;
|
2026-10-01 23:12:21 +03:00
|
|
|
|
|
|
|
|
pub fn view(app: &Carrier) -> Element<'_, Message> {
|
|
|
|
|
let content: Element<'_, Message> = if app.compose.is_some() {
|
|
|
|
|
compose(app)
|
|
|
|
|
} else {
|
|
|
|
|
match app.page {
|
|
|
|
|
Page::Onboard => onboard(app),
|
|
|
|
|
Page::Signup => signup(app),
|
|
|
|
|
Page::Mail => mail(app),
|
|
|
|
|
Page::Contacts => contacts(app),
|
|
|
|
|
Page::Settings => settings(app),
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
column![content, dialog_banner(app), status(app)]
|
|
|
|
|
.spacing(0)
|
|
|
|
|
.height(Length::Fill)
|
|
|
|
|
.width(Length::Fill)
|
|
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- shared pieces ---------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/// One line of monospace UI text.
|
|
|
|
|
fn mono<'a>(content: impl std::fmt::Display) -> iced::widget::Text<'a> {
|
|
|
|
|
text(content.to_string())
|
|
|
|
|
.font(Font::MONOSPACE)
|
2026-10-02 00:08:48 +03:00
|
|
|
.size(S)
|
2026-10-01 23:12:21 +03:00
|
|
|
.color(TEXT)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn muted<'a>(content: impl std::fmt::Display) -> iced::widget::Text<'a> {
|
|
|
|
|
text(content.to_string())
|
|
|
|
|
.font(Font::MONOSPACE)
|
2026-10-02 00:08:48 +03:00
|
|
|
.size(S)
|
2026-10-01 23:12:21 +03:00
|
|
|
.color(MUTED)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn accent<'a>(content: impl std::fmt::Display) -> iced::widget::Text<'a> {
|
|
|
|
|
text(content.to_string())
|
|
|
|
|
.font(Font::MONOSPACE)
|
2026-10-02 00:08:48 +03:00
|
|
|
.size(S)
|
2026-10-01 23:12:21 +03:00
|
|
|
.color(ACCENT)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A fixed-width cell so every row lines up like a table column.
|
|
|
|
|
fn cell<'a>(content: iced::widget::Text<'a>, width: f32) -> Element<'a, Message> {
|
|
|
|
|
container(content)
|
|
|
|
|
.width(width)
|
|
|
|
|
.align_x(Alignment::Start)
|
|
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn action<'a>(label: &str, msg: Message) -> Element<'a, Message> {
|
2026-10-02 00:08:48 +03:00
|
|
|
button(text(label.to_string()).size(S))
|
2026-10-01 23:12:21 +03:00
|
|
|
.on_press(msg)
|
|
|
|
|
.style(theme::accent())
|
2026-10-02 00:08:48 +03:00
|
|
|
.padding([4, 12])
|
2026-10-01 23:12:21 +03:00
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn action_disabled<'a>(label: &str) -> Element<'a, Message> {
|
2026-10-02 00:08:48 +03:00
|
|
|
button(text(label.to_string()).size(S))
|
|
|
|
|
.padding([4, 12])
|
2026-10-01 23:12:21 +03:00
|
|
|
.style(theme::quiet())
|
|
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn quiet_action<'a>(label: &str, msg: Message) -> Element<'a, Message> {
|
2026-10-02 00:08:48 +03:00
|
|
|
button(text(label.to_string()).size(S))
|
2026-10-01 23:12:21 +03:00
|
|
|
.on_press(msg)
|
|
|
|
|
.style(theme::quiet())
|
2026-10-02 00:08:48 +03:00
|
|
|
.padding([4, 12])
|
2026-10-01 23:12:21 +03:00
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn danger_action<'a>(label: &str, msg: Message) -> Element<'a, Message> {
|
2026-10-02 00:08:48 +03:00
|
|
|
button(text(label.to_string()).size(S))
|
2026-10-01 23:12:21 +03:00
|
|
|
.on_press(msg)
|
|
|
|
|
.style(theme::danger())
|
2026-10-02 00:08:48 +03:00
|
|
|
.padding([4, 12])
|
2026-10-01 23:12:21 +03:00
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A tab entry: accent text when active, muted otherwise.
|
|
|
|
|
fn tab<'a>(label: impl std::fmt::Display, active: bool, msg: Message) -> Element<'a, Message> {
|
|
|
|
|
let label = label.to_string();
|
|
|
|
|
button(
|
|
|
|
|
text(label)
|
|
|
|
|
.font(Font::MONOSPACE)
|
2026-10-02 00:08:48 +03:00
|
|
|
.size(S)
|
2026-10-01 23:12:21 +03:00
|
|
|
.color(if active { ACCENT } else { MUTED }),
|
|
|
|
|
)
|
|
|
|
|
.on_press(msg)
|
2026-10-02 00:08:48 +03:00
|
|
|
.padding([4, 8])
|
2026-10-01 23:12:21 +03:00
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The bar every main page shares: identity, folder tabs, page tabs, actions.
|
|
|
|
|
fn top_bar<'a>(app: &'a Carrier) -> Element<'a, Message> {
|
|
|
|
|
let address = app
|
|
|
|
|
.snapshot
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|snap| snap.who.address.clone())
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
let unread = |folder: usize| {
|
|
|
|
|
app.lists
|
|
|
|
|
.get(folder)
|
|
|
|
|
.map(|rows| rows.iter().filter(|r| r.unread).count())
|
|
|
|
|
.unwrap_or(0)
|
|
|
|
|
};
|
|
|
|
|
let fetching = app.busy.as_deref() == Some("fetching");
|
|
|
|
|
let mut tabs = row![].spacing(2);
|
|
|
|
|
for (folder, label) in [
|
|
|
|
|
(Folder::Inbox, "Inbox"),
|
|
|
|
|
(Folder::Requests, "Requests"),
|
|
|
|
|
(Folder::Sent, "Sent"),
|
|
|
|
|
] {
|
|
|
|
|
let count = unread(folder as usize);
|
|
|
|
|
let label = if count > 0 {
|
|
|
|
|
format!("{label} {count}")
|
|
|
|
|
} else {
|
|
|
|
|
label.to_string()
|
|
|
|
|
};
|
|
|
|
|
tabs = tabs.push(tab(
|
|
|
|
|
label,
|
|
|
|
|
app.folder == folder && app.page == Page::Mail,
|
|
|
|
|
Message::GotoMail(folder),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
tabs = tabs
|
|
|
|
|
.push(tab(
|
|
|
|
|
"Contacts",
|
|
|
|
|
app.page == Page::Contacts,
|
|
|
|
|
Message::Goto(Page::Contacts),
|
|
|
|
|
))
|
|
|
|
|
.push(tab(
|
|
|
|
|
"Settings",
|
|
|
|
|
app.page == Page::Settings,
|
|
|
|
|
Message::Goto(Page::Settings),
|
|
|
|
|
));
|
|
|
|
|
let fetch = if fetching {
|
|
|
|
|
action_disabled("Fetching…")
|
|
|
|
|
} else if app.busy.is_some() {
|
|
|
|
|
action_disabled("Fetch")
|
|
|
|
|
} else {
|
|
|
|
|
action("Fetch", Message::FetchPressed)
|
|
|
|
|
};
|
|
|
|
|
let write = if app.busy.is_some() {
|
|
|
|
|
action_disabled("Write")
|
|
|
|
|
} else {
|
|
|
|
|
action("Write", Message::ComposePressed)
|
|
|
|
|
};
|
|
|
|
|
container(
|
|
|
|
|
row![
|
|
|
|
|
accent("carrier"),
|
|
|
|
|
mono(address),
|
|
|
|
|
Space::with_width(Length::Fill),
|
|
|
|
|
tabs,
|
|
|
|
|
Space::with_width(Length::Fill),
|
|
|
|
|
fetch,
|
|
|
|
|
write,
|
|
|
|
|
]
|
|
|
|
|
.spacing(10)
|
|
|
|
|
.align_y(Alignment::Center),
|
|
|
|
|
)
|
|
|
|
|
.style(theme::panel)
|
|
|
|
|
.padding([6, 10])
|
|
|
|
|
.width(Length::Fill)
|
|
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The one status line: the busy label with its cancel button while an
|
|
|
|
|
/// operation runs, otherwise the last result or error.
|
|
|
|
|
fn status<'a>(app: &'a Carrier) -> Element<'a, Message> {
|
|
|
|
|
let mut line = row![].spacing(10).align_y(Alignment::Center);
|
|
|
|
|
match (&app.busy, &app.status) {
|
|
|
|
|
(Some(busy), _) => {
|
|
|
|
|
if busy == "fetching" {
|
2026-10-02 00:00:41 +03:00
|
|
|
let inbox = app.lists.first().map(|rows| rows.len()).unwrap_or(0);
|
|
|
|
|
line = line.push(muted(format!("fetching… {inbox} in inbox")));
|
2026-10-01 23:12:21 +03:00
|
|
|
line = line.push(quiet_action("Cancel", Message::FetchCancelled));
|
2026-10-02 00:00:41 +03:00
|
|
|
} else {
|
|
|
|
|
line = line.push(muted(format!("{busy}…")));
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
(None, Some(status)) => {
|
|
|
|
|
let piece = if app.status_error {
|
2026-10-02 00:08:48 +03:00
|
|
|
text(status.clone()).font(Font::MONOSPACE).size(S)
|
2026-10-01 23:12:21 +03:00
|
|
|
} else {
|
|
|
|
|
muted(status.clone())
|
|
|
|
|
};
|
|
|
|
|
line = line.push(piece);
|
|
|
|
|
}
|
|
|
|
|
(None, None) => {}
|
|
|
|
|
}
|
|
|
|
|
container(line)
|
|
|
|
|
.style(theme::status(app.status_error && app.busy.is_none()))
|
|
|
|
|
.padding([4, 10])
|
|
|
|
|
.width(Length::Fill)
|
|
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Inline confirmations: a slim banner between the content and the status.
|
|
|
|
|
fn dialog_banner<'a>(app: &'a Carrier) -> Element<'a, Message> {
|
|
|
|
|
let Some(dialog) = &app.dialog else {
|
|
|
|
|
return row![].into();
|
|
|
|
|
};
|
|
|
|
|
let mut line = row![].spacing(8).align_y(Alignment::Center);
|
|
|
|
|
match dialog {
|
|
|
|
|
Dialog::Delete { kept, .. } => {
|
|
|
|
|
line = line.push(muted("delete this message:"));
|
|
|
|
|
line = line.push(danger_action("here", Message::DeleteConfirmed(false)));
|
|
|
|
|
if *kept {
|
|
|
|
|
line = line.push(danger_action("everywhere", Message::DeleteConfirmed(true)));
|
|
|
|
|
}
|
|
|
|
|
line = line.push(quiet_action("cancel", Message::DeleteCancelled));
|
|
|
|
|
}
|
|
|
|
|
Dialog::Rotate => {
|
|
|
|
|
line = line.push(muted(
|
|
|
|
|
"rotate the identity? the old key stays derivable; the index never moves back",
|
|
|
|
|
));
|
|
|
|
|
line = line.push(action("rotate", Message::RotateConfirmed));
|
|
|
|
|
line = line.push(quiet_action("cancel", Message::RotateCancelled));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
container(line)
|
|
|
|
|
.style(theme::status(false))
|
|
|
|
|
.padding([4, 10])
|
|
|
|
|
.width(Length::Fill)
|
|
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn pin_offer_banner<'a>(app: &'a Carrier) -> Element<'a, Message> {
|
|
|
|
|
let Some((_, key)) = &app.pin_offer else {
|
|
|
|
|
return row![].into();
|
|
|
|
|
};
|
|
|
|
|
row![
|
|
|
|
|
muted("server key offered:"),
|
|
|
|
|
mono(fingerprint(key)),
|
|
|
|
|
mono(short(&b32(key), 24)),
|
|
|
|
|
action("pin", Message::PinAccept),
|
|
|
|
|
quiet_action("discard", Message::PinDiscard),
|
|
|
|
|
]
|
|
|
|
|
.spacing(8)
|
|
|
|
|
.align_y(Alignment::Center)
|
|
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- mail ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
fn mail<'a>(app: &'a Carrier) -> Element<'a, Message> {
|
|
|
|
|
column![
|
|
|
|
|
top_bar(app),
|
|
|
|
|
container(list_pane(app)).height(Length::FillPortion(38)),
|
|
|
|
|
rule::Rule::horizontal(1.0),
|
|
|
|
|
container(detail_pane(app)).height(Length::FillPortion(62)),
|
|
|
|
|
]
|
|
|
|
|
.spacing(0)
|
|
|
|
|
.height(Length::Fill)
|
|
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn list_pane<'a>(app: &'a Carrier) -> Element<'a, Message> {
|
|
|
|
|
let rows = app
|
|
|
|
|
.lists
|
|
|
|
|
.get(app.folder as usize)
|
|
|
|
|
.cloned()
|
|
|
|
|
.unwrap_or_default();
|
2026-10-02 00:08:48 +03:00
|
|
|
let mut list = column![].spacing(2);
|
2026-10-01 23:12:21 +03:00
|
|
|
if rows.is_empty() {
|
|
|
|
|
let hint = match app.folder {
|
|
|
|
|
Folder::Inbox => "inbox is empty — fetch to check the mailbox",
|
|
|
|
|
Folder::Requests => "no requests",
|
|
|
|
|
Folder::Sent => "nothing sent yet",
|
|
|
|
|
};
|
2026-10-02 00:08:48 +03:00
|
|
|
list = list.push(container(muted(hint)).padding(Padding::new(14.0)));
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
|
|
|
|
for row in &rows {
|
|
|
|
|
let selected = app
|
|
|
|
|
.open
|
|
|
|
|
.as_ref()
|
|
|
|
|
.is_some_and(|(folder, msg)| *folder == app.folder && msg.id == row.id);
|
|
|
|
|
list = list.push(list_row(app.folder, row, selected));
|
|
|
|
|
}
|
|
|
|
|
scrollable(list).height(Length::Fill).into()
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-02 00:08:48 +03:00
|
|
|
/// One row, like a note title: unread flag and subject on the left, sender
|
|
|
|
|
/// and date muted on the right — no column chrome, the whole line clicks.
|
2026-10-01 23:12:21 +03:00
|
|
|
fn list_row<'a>(folder: Folder, row: &Row, selected: bool) -> Element<'a, Message> {
|
|
|
|
|
let flag = if row.unread {
|
|
|
|
|
accent("●")
|
|
|
|
|
} else {
|
|
|
|
|
muted("·")
|
|
|
|
|
};
|
2026-10-02 00:08:48 +03:00
|
|
|
let subject = if row.subject.is_empty() {
|
|
|
|
|
"(no subject)".to_string()
|
|
|
|
|
} else {
|
|
|
|
|
truncate(&row.subject, 56)
|
|
|
|
|
};
|
2026-10-01 23:12:21 +03:00
|
|
|
button(
|
|
|
|
|
row![
|
2026-10-02 00:08:48 +03:00
|
|
|
flag,
|
|
|
|
|
text(subject)
|
2026-10-01 23:12:21 +03:00
|
|
|
.font(Font::MONOSPACE)
|
2026-10-02 00:08:48 +03:00
|
|
|
.size(S)
|
2026-10-01 23:12:21 +03:00
|
|
|
.color(if row.unread { TEXT } else { MUTED })
|
|
|
|
|
.wrapping(Wrapping::None),
|
2026-10-02 00:08:48 +03:00
|
|
|
Space::with_width(Length::Fill),
|
|
|
|
|
muted(truncate(&row.from, 24)),
|
|
|
|
|
muted(ops::when(row.at)),
|
2026-10-01 23:12:21 +03:00
|
|
|
]
|
|
|
|
|
.spacing(10)
|
|
|
|
|
.align_y(Alignment::Center),
|
|
|
|
|
)
|
|
|
|
|
.on_press(Message::MessageSelected(folder, row.id.clone()))
|
|
|
|
|
.style(theme::row_style(selected))
|
2026-10-02 00:08:48 +03:00
|
|
|
.padding(Padding::new(4.0).left(12.0).right(12.0))
|
2026-10-01 23:12:21 +03:00
|
|
|
.width(Length::Fill)
|
|
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-02 00:08:48 +03:00
|
|
|
/// A `label: value` line for the settings page.
|
2026-10-01 23:12:21 +03:00
|
|
|
fn header_line<'a>(label: &str, value: impl std::fmt::Display) -> Element<'a, Message> {
|
2026-10-02 00:08:48 +03:00
|
|
|
row![muted(format!("{label:<12}")), mono(value),]
|
2026-10-01 23:12:21 +03:00
|
|
|
.spacing(4)
|
|
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn detail_pane<'a>(app: &'a Carrier) -> Element<'a, Message> {
|
|
|
|
|
let Some((folder, msg)) = &app.open else {
|
|
|
|
|
return container(muted("select a message to read it"))
|
|
|
|
|
.center(Length::Fill)
|
|
|
|
|
.into();
|
|
|
|
|
};
|
2026-10-02 00:08:48 +03:00
|
|
|
// One muted meta line carries what the pane used to stack as headers.
|
|
|
|
|
let label = if *folder == Folder::Sent {
|
2026-10-01 23:12:21 +03:00
|
|
|
"to"
|
|
|
|
|
} else {
|
|
|
|
|
"from"
|
|
|
|
|
};
|
2026-10-02 00:08:48 +03:00
|
|
|
let mut meta = format!("{label} {} · {} UTC", msg.from, ops::when(msg.time));
|
|
|
|
|
meta.push_str(&format!(" · id {}", short(&msg.id, 12)));
|
2026-10-01 23:12:21 +03:00
|
|
|
if msg.kept {
|
2026-10-02 00:08:48 +03:00
|
|
|
meta.push_str(" · a server copy still exists");
|
2026-10-01 23:12:21 +03:00
|
|
|
}
|
2026-10-02 00:08:48 +03:00
|
|
|
let subject = if msg.subject.is_empty() {
|
|
|
|
|
"(no subject)".to_string()
|
|
|
|
|
} else {
|
|
|
|
|
msg.subject.clone()
|
|
|
|
|
};
|
2026-10-01 23:12:21 +03:00
|
|
|
let mut actions = row![].spacing(8);
|
|
|
|
|
actions = actions.push(quiet_action("reply", Message::ReplyPressed));
|
|
|
|
|
if *folder == Folder::Requests {
|
|
|
|
|
if msg.reply_to.is_some() {
|
|
|
|
|
actions = actions.push(action("accept", Message::AcceptPressed));
|
|
|
|
|
actions = actions.push(danger_action("block", Message::BlockPressed));
|
|
|
|
|
} else {
|
|
|
|
|
actions = actions.push(muted("no reply address: cannot accept or block"));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
actions = actions.push(danger_action("delete", Message::DeletePressed));
|
|
|
|
|
column![
|
2026-10-02 00:08:48 +03:00
|
|
|
container(
|
|
|
|
|
column![
|
|
|
|
|
text(subject)
|
|
|
|
|
.font(Font::MONOSPACE)
|
|
|
|
|
.size(BIG)
|
|
|
|
|
.color(TEXT)
|
|
|
|
|
.wrapping(Wrapping::None),
|
|
|
|
|
muted(meta),
|
|
|
|
|
]
|
|
|
|
|
.spacing(4),
|
|
|
|
|
)
|
|
|
|
|
.padding([10, 12]),
|
2026-10-01 23:12:21 +03:00
|
|
|
rule::Rule::horizontal(1.0),
|
|
|
|
|
scrollable(column![container(
|
|
|
|
|
text(msg.body.clone())
|
|
|
|
|
.font(Font::MONOSPACE)
|
2026-10-02 00:08:48 +03:00
|
|
|
.size(BODY)
|
2026-10-01 23:12:21 +03:00
|
|
|
.color(TEXT)
|
|
|
|
|
)
|
2026-10-02 00:08:48 +03:00
|
|
|
.padding([10, 12])])
|
2026-10-01 23:12:21 +03:00
|
|
|
.height(Length::Fill),
|
|
|
|
|
rule::Rule::horizontal(1.0),
|
2026-10-02 00:08:48 +03:00
|
|
|
container(actions).padding([6, 12]),
|
2026-10-01 23:12:21 +03:00
|
|
|
]
|
|
|
|
|
.spacing(0)
|
|
|
|
|
.height(Length::Fill)
|
|
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- compose ---------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/// Compose takes the lower pane, keeping the list visible above — writing is
|
|
|
|
|
/// the article pane's other job, as in the layout it comes from.
|
|
|
|
|
fn compose<'a>(app: &'a Carrier) -> Element<'a, Message> {
|
|
|
|
|
let Some(compose) = &app.compose else {
|
|
|
|
|
unreachable!("compose view without a draft")
|
|
|
|
|
};
|
|
|
|
|
let label = if compose.reply_to.is_some() {
|
|
|
|
|
"reply"
|
|
|
|
|
} else {
|
|
|
|
|
"write"
|
|
|
|
|
};
|
|
|
|
|
column![
|
|
|
|
|
top_bar(app),
|
|
|
|
|
container(
|
|
|
|
|
column![
|
2026-10-02 00:08:48 +03:00
|
|
|
text_input("to — alice@example.org or smol://…", &compose.to)
|
|
|
|
|
.on_input(Message::ComposeToEdited)
|
|
|
|
|
.size(S)
|
|
|
|
|
.width(Length::Fill),
|
|
|
|
|
text_input("subject", &compose.subject)
|
|
|
|
|
.on_input(Message::ComposeSubjectEdited)
|
|
|
|
|
.size(S)
|
|
|
|
|
.width(Length::Fill),
|
2026-10-01 23:12:21 +03:00
|
|
|
text_editor(&compose.body)
|
|
|
|
|
.on_action(Message::ComposeBody)
|
|
|
|
|
.font(Font::MONOSPACE)
|
2026-10-02 00:08:48 +03:00
|
|
|
.size(BODY)
|
2026-10-01 23:12:21 +03:00
|
|
|
.height(Length::Fill),
|
|
|
|
|
row![
|
|
|
|
|
action(label, Message::SendPressed),
|
|
|
|
|
quiet_action("cancel", Message::ComposeClosed),
|
|
|
|
|
]
|
|
|
|
|
.spacing(8),
|
|
|
|
|
]
|
2026-10-02 00:08:48 +03:00
|
|
|
.spacing(8)
|
2026-10-01 23:12:21 +03:00
|
|
|
)
|
2026-10-02 00:08:48 +03:00
|
|
|
.padding(12)
|
2026-10-01 23:12:21 +03:00
|
|
|
.height(Length::Fill),
|
|
|
|
|
]
|
|
|
|
|
.spacing(0)
|
|
|
|
|
.height(Length::Fill)
|
|
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- contacts ---------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
fn contacts<'a>(app: &'a Carrier) -> Element<'a, Message> {
|
|
|
|
|
let Some(snap) = &app.snapshot else {
|
|
|
|
|
return column![top_bar(app)].into();
|
|
|
|
|
};
|
|
|
|
|
let own = format!("smol://{}/{}", snap.who.address, snap.who.key);
|
|
|
|
|
let mut share = row![
|
|
|
|
|
muted("you:"),
|
|
|
|
|
mono(own),
|
|
|
|
|
quiet_action("copy", Message::CopyOwnAddress),
|
|
|
|
|
]
|
|
|
|
|
.spacing(8)
|
|
|
|
|
.align_y(Alignment::Center);
|
|
|
|
|
if let Some(data) = &app.share_qr {
|
|
|
|
|
share = share.push(container(qr_code(data).cell_size(3)).padding(0));
|
|
|
|
|
}
|
|
|
|
|
let header = row![
|
|
|
|
|
cell(muted("address"), ADDR_W),
|
|
|
|
|
cell(muted("key"), KEY_W),
|
|
|
|
|
cell(muted("state"), STATE_W),
|
|
|
|
|
muted(""),
|
|
|
|
|
]
|
|
|
|
|
.spacing(10)
|
2026-10-02 00:08:48 +03:00
|
|
|
.padding(Padding::new(4.0).left(12.0).right(12.0));
|
2026-10-01 23:12:21 +03:00
|
|
|
let mut table = column![header].spacing(1);
|
|
|
|
|
for contact in &snap.contacts {
|
|
|
|
|
table = table.push(contact_row(contact));
|
|
|
|
|
}
|
|
|
|
|
if snap.contacts.is_empty() {
|
|
|
|
|
table = table.push(
|
|
|
|
|
container(muted("no contacts yet — import a smol:// address"))
|
|
|
|
|
.padding(Padding::new(10.0)),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
column![
|
|
|
|
|
top_bar(app),
|
|
|
|
|
container(share).style(theme::panel).padding([6, 10]),
|
|
|
|
|
container(
|
|
|
|
|
row![
|
|
|
|
|
text_input("paste a smol:// address to import", &app.contact_import)
|
|
|
|
|
.on_input(Message::ContactImportEdited)
|
|
|
|
|
.on_submit(Message::ContactImportPressed)
|
2026-10-02 00:08:48 +03:00
|
|
|
.size(S)
|
2026-10-01 23:12:21 +03:00
|
|
|
.width(Length::Fill),
|
|
|
|
|
action("import", Message::ContactImportPressed),
|
|
|
|
|
]
|
|
|
|
|
.spacing(8)
|
|
|
|
|
)
|
|
|
|
|
.padding([6, 10]),
|
|
|
|
|
scrollable(table).height(Length::Fill),
|
|
|
|
|
]
|
|
|
|
|
.spacing(0)
|
|
|
|
|
.height(Length::Fill)
|
|
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn contact_row<'a>(contact: &Contact) -> Element<'a, Message> {
|
|
|
|
|
let state = match contact.accepted {
|
|
|
|
|
Some(true) => "accepted",
|
|
|
|
|
Some(false) => "blocked",
|
|
|
|
|
None => {
|
|
|
|
|
if contact.verified {
|
|
|
|
|
"verified"
|
|
|
|
|
} else {
|
|
|
|
|
"tofu"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
let mut actions = row![quiet_action(
|
|
|
|
|
"write",
|
|
|
|
|
Message::ContactWrite(contact.address.clone()),
|
|
|
|
|
)]
|
|
|
|
|
.spacing(6);
|
|
|
|
|
match contact.accepted {
|
|
|
|
|
Some(true) => {
|
|
|
|
|
actions = actions.push(danger_action(
|
|
|
|
|
"block",
|
|
|
|
|
Message::ContactBlock(contact.address.clone()),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Some(false) => {
|
|
|
|
|
actions = actions.push(action(
|
|
|
|
|
"unblock",
|
|
|
|
|
Message::ContactUnblock(contact.address.clone()),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
None => {
|
|
|
|
|
actions = actions.push(action(
|
|
|
|
|
"accept",
|
|
|
|
|
Message::ContactAccept(contact.address.clone(), contact.key.clone()),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
container(
|
|
|
|
|
row![
|
|
|
|
|
cell(accent(&contact.address), ADDR_W),
|
|
|
|
|
cell(mono(short(&contact.key, 32)), KEY_W),
|
|
|
|
|
cell(muted(state), STATE_W),
|
|
|
|
|
actions,
|
|
|
|
|
]
|
|
|
|
|
.spacing(10)
|
|
|
|
|
.align_y(Alignment::Center),
|
|
|
|
|
)
|
|
|
|
|
.style(theme::card)
|
2026-10-02 00:08:48 +03:00
|
|
|
.padding(Padding::new(4.0).left(12.0).right(12.0))
|
2026-10-01 23:12:21 +03:00
|
|
|
.width(Length::Fill)
|
|
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- settings ---------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
fn settings<'a>(app: &'a Carrier) -> Element<'a, Message> {
|
|
|
|
|
let Some(snap) = &app.snapshot else {
|
|
|
|
|
return column![top_bar(app)].into();
|
|
|
|
|
};
|
|
|
|
|
let who = &snap.who;
|
|
|
|
|
let identity = column![
|
|
|
|
|
header_line("address", &who.address),
|
|
|
|
|
header_line("key", &who.key),
|
|
|
|
|
header_line("fingerprint", &who.fingerprint),
|
|
|
|
|
header_line("rotations", who.rotations),
|
|
|
|
|
row![
|
|
|
|
|
action("rotate", Message::RotatePressed),
|
|
|
|
|
quiet_action("learn key", Message::SettingsLearnKey),
|
|
|
|
|
]
|
|
|
|
|
.spacing(8),
|
|
|
|
|
]
|
|
|
|
|
.spacing(1);
|
|
|
|
|
|
|
|
|
|
let mut pins = column![].spacing(1);
|
|
|
|
|
for (label, key) in &snap.pins {
|
|
|
|
|
pins = pins.push(
|
|
|
|
|
row![
|
|
|
|
|
cell(mono(label), ADDR_W),
|
|
|
|
|
cell(mono(short(&b32(key), 32)), KEY_W),
|
|
|
|
|
danger_action("unpin", Message::UnpinPressed(label.clone())),
|
|
|
|
|
]
|
|
|
|
|
.spacing(10)
|
|
|
|
|
.align_y(Alignment::Center),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if snap.pins.is_empty() {
|
|
|
|
|
pins = pins.push(muted("no server keys pinned"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Pinned to the app borrow: the column mixes owned rows with the
|
|
|
|
|
// `text_editor` that borrows `app.import_body`.
|
|
|
|
|
let mut export: iced::widget::Column<'_, Message> = column![].spacing(4);
|
|
|
|
|
export = export.push(
|
|
|
|
|
row![
|
|
|
|
|
action("export", Message::ExportPressed),
|
|
|
|
|
quiet_action("copy", Message::CopyExport),
|
|
|
|
|
]
|
|
|
|
|
.spacing(8),
|
|
|
|
|
);
|
|
|
|
|
if let Some(text) = &app.backup_text {
|
|
|
|
|
export = export.push(scrollable(mono(short(text, 4000))).height(Length::Fixed(80.0)));
|
|
|
|
|
}
|
|
|
|
|
export = export.push(muted("import a backup (gsmol): paste it below"));
|
|
|
|
|
export = export.push(
|
|
|
|
|
text_editor(&app.import_body)
|
|
|
|
|
.on_action(Message::ImportBody)
|
|
|
|
|
.font(Font::MONOSPACE)
|
2026-10-02 00:08:48 +03:00
|
|
|
.size(S)
|
2026-10-01 23:12:21 +03:00
|
|
|
.height(Length::Fixed(100.0)),
|
|
|
|
|
);
|
|
|
|
|
export = export.push(action("import", Message::ImportPressed));
|
|
|
|
|
|
|
|
|
|
let sections = column![
|
|
|
|
|
section("identity", identity.into()),
|
|
|
|
|
section("server keys", pins.into()),
|
|
|
|
|
section("backup", export.into()),
|
|
|
|
|
section(
|
|
|
|
|
"network",
|
|
|
|
|
row![
|
|
|
|
|
muted("timeout"),
|
|
|
|
|
text_input("30", &app.timeout_text)
|
|
|
|
|
.on_input(Message::TimeoutEdited)
|
2026-10-02 00:08:48 +03:00
|
|
|
.size(S)
|
2026-10-01 23:12:21 +03:00
|
|
|
.width(80),
|
|
|
|
|
muted("seconds"),
|
|
|
|
|
]
|
|
|
|
|
.spacing(8)
|
|
|
|
|
.align_y(Alignment::Center)
|
|
|
|
|
.into(),
|
|
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
.spacing(10);
|
|
|
|
|
|
|
|
|
|
column![
|
|
|
|
|
top_bar(app),
|
|
|
|
|
pin_offer_banner(app),
|
|
|
|
|
scrollable(container(sections).padding(10)).height(Length::Fill),
|
|
|
|
|
]
|
|
|
|
|
.spacing(0)
|
|
|
|
|
.height(Length::Fill)
|
|
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn section<'a>(title: &str, body: Element<'a, Message>) -> Element<'a, Message> {
|
|
|
|
|
container(
|
|
|
|
|
column![container(accent(title)).padding([2, 0]), body]
|
|
|
|
|
.spacing(6)
|
|
|
|
|
.max_width(720),
|
|
|
|
|
)
|
|
|
|
|
.style(theme::card)
|
|
|
|
|
.padding(10)
|
|
|
|
|
.width(Length::Fill)
|
|
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- onboarding and signup ---------------------------------------------------
|
|
|
|
|
|
|
|
|
|
fn centered<'a>(body: Element<'a, Message>) -> Element<'a, Message> {
|
|
|
|
|
container(body)
|
|
|
|
|
.center(Length::Fill)
|
|
|
|
|
.max_width(520)
|
|
|
|
|
.padding(20)
|
|
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn onboard<'a>(app: &'a Carrier) -> Element<'a, Message> {
|
|
|
|
|
let body = match app.onboard.mode {
|
|
|
|
|
OnboardMode::Start => column![
|
|
|
|
|
text("carrier").font(Font::MONOSPACE).size(24).color(ACCENT),
|
|
|
|
|
muted("smolmail: one seed, sealed mail, five operations"),
|
|
|
|
|
row![
|
|
|
|
|
action("create an identity", Message::OnboardCreate),
|
|
|
|
|
quiet_action("restore from a seed", Message::OnboardRestore),
|
|
|
|
|
]
|
|
|
|
|
.spacing(8)
|
|
|
|
|
.padding([12, 0]),
|
|
|
|
|
]
|
|
|
|
|
.spacing(10)
|
|
|
|
|
.align_x(Alignment::Start),
|
|
|
|
|
OnboardMode::Created => column![
|
|
|
|
|
muted("this seed is the only secret — write it down now; it is shown once"),
|
|
|
|
|
container(
|
|
|
|
|
text_input("", &app.onboard.seed_shown)
|
|
|
|
|
.font(Font::MONOSPACE)
|
2026-10-02 00:08:48 +03:00
|
|
|
.size(S)
|
2026-10-01 23:12:21 +03:00
|
|
|
.width(Length::Fill)
|
|
|
|
|
)
|
|
|
|
|
.style(theme::card)
|
|
|
|
|
.padding(6),
|
|
|
|
|
row![
|
|
|
|
|
action("i saved it", Message::OnboardSeedSaved),
|
|
|
|
|
quiet_action("back", Message::OnboardBack),
|
|
|
|
|
]
|
|
|
|
|
.spacing(8),
|
|
|
|
|
]
|
|
|
|
|
.spacing(10),
|
|
|
|
|
OnboardMode::Restore => column![
|
|
|
|
|
muted("paste the 32-byte seed, base32 or hex"),
|
|
|
|
|
text_input("seed", &app.onboard.seed)
|
|
|
|
|
.on_input(Message::OnboardSeedEdited)
|
|
|
|
|
.on_submit(Message::OnboardRestoreFromSeed)
|
2026-10-02 00:08:48 +03:00
|
|
|
.size(S)
|
2026-10-01 23:12:21 +03:00
|
|
|
.width(Length::Fill),
|
|
|
|
|
row![
|
|
|
|
|
action("restore", Message::OnboardRestoreFromSeed),
|
|
|
|
|
quiet_action("back", Message::OnboardBack),
|
|
|
|
|
]
|
|
|
|
|
.spacing(8),
|
|
|
|
|
]
|
|
|
|
|
.spacing(10),
|
|
|
|
|
};
|
|
|
|
|
centered(body.into())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn signup<'a>(app: &'a Carrier) -> Element<'a, Message> {
|
|
|
|
|
let mut body = column![
|
|
|
|
|
accent("set up the address"),
|
2026-10-02 00:08:48 +03:00
|
|
|
text_input("user — alice", &app.signup.user)
|
|
|
|
|
.on_input(Message::SignupUserEdited)
|
|
|
|
|
.size(S)
|
|
|
|
|
.width(Length::Fill),
|
|
|
|
|
text_input("host — example.org", &app.signup.host)
|
|
|
|
|
.on_input(Message::SignupHostEdited)
|
|
|
|
|
.size(S)
|
|
|
|
|
.width(Length::Fill),
|
|
|
|
|
text_input(
|
|
|
|
|
"invite token, if the server requires one",
|
|
|
|
|
&app.signup.invite
|
|
|
|
|
)
|
|
|
|
|
.on_input(Message::SignupInviteEdited)
|
|
|
|
|
.size(S)
|
|
|
|
|
.width(Length::Fill),
|
|
|
|
|
text_input("server key, if the operator published one", &app.signup.key,)
|
|
|
|
|
.on_input(Message::SignupKeyEdited)
|
|
|
|
|
.size(S)
|
|
|
|
|
.width(Length::Fill),
|
2026-10-01 23:12:21 +03:00
|
|
|
]
|
|
|
|
|
.spacing(8);
|
|
|
|
|
if app.signup.key.is_empty() {
|
|
|
|
|
body = body.push(
|
|
|
|
|
row![
|
|
|
|
|
muted("or"),
|
|
|
|
|
quiet_action("learn the server key", Message::SignupLearnKey),
|
|
|
|
|
muted("and pin what it presents"),
|
|
|
|
|
]
|
|
|
|
|
.spacing(8)
|
|
|
|
|
.align_y(Alignment::Center),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
body = body.push(pin_offer_banner(app));
|
|
|
|
|
body = body.push(
|
|
|
|
|
row![
|
|
|
|
|
action("register", Message::SignupRegister),
|
|
|
|
|
quiet_action("i already have this address", Message::SignupRecall),
|
|
|
|
|
]
|
|
|
|
|
.spacing(8)
|
|
|
|
|
.padding([8, 0]),
|
|
|
|
|
);
|
|
|
|
|
if app.signup.fresh {
|
|
|
|
|
body = body.push(quiet_action("back", Message::SignupBack));
|
|
|
|
|
}
|
|
|
|
|
centered(body.into())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- small helpers -----------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
fn truncate(text: &str, max: usize) -> String {
|
|
|
|
|
if text.chars().count() <= max {
|
|
|
|
|
text.to_string()
|
|
|
|
|
} else {
|
|
|
|
|
let cut: String = text.chars().take(max - 1).collect();
|
|
|
|
|
format!("{cut}…")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn short(text: &str, max: usize) -> String {
|
|
|
|
|
if text.len() <= max {
|
|
|
|
|
text.to_string()
|
|
|
|
|
} else {
|
|
|
|
|
format!("{}…{}", &text[..max / 2], &text[text.len() - 3..])
|
|
|
|
|
}
|
|
|
|
|
}
|