Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8982df364e |
+85
-147
@@ -30,25 +30,6 @@ pub enum Screen {
|
||||
Settings,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum HomeLayoutMode {
|
||||
FocusedEmpty,
|
||||
ThreeColumn,
|
||||
Stacked,
|
||||
}
|
||||
|
||||
fn home_layout_mode(width: f32, has_recents: bool, has_friends: bool) -> HomeLayoutMode {
|
||||
if width < 900.0 {
|
||||
HomeLayoutMode::Stacked
|
||||
} else if !has_recents && !has_friends {
|
||||
HomeLayoutMode::FocusedEmpty
|
||||
} else if width >= 1280.0 {
|
||||
HomeLayoutMode::ThreeColumn
|
||||
} else {
|
||||
HomeLayoutMode::Stacked
|
||||
}
|
||||
}
|
||||
|
||||
/// One rendered room-chat line. `mine` distinguishes our own (locally echoed)
|
||||
/// messages from peers' for colouring.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -1354,7 +1335,7 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> {
|
||||
selection: color_blue,
|
||||
};
|
||||
|
||||
let logo = text("PEERSPEAK").size(38).color(color_blue);
|
||||
let logo = text("PEERSPEAK").size(36).color(color_blue);
|
||||
let subtitle = text("NAT-traversing full-mesh voice chat").size(16).color(color_subtext);
|
||||
|
||||
let nickname_input = column![
|
||||
@@ -1418,8 +1399,8 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.align_x(iced::alignment::Horizontal::Center),
|
||||
)
|
||||
.style(c_style(color_mantle, color_surface, 12.0))
|
||||
.padding(32)
|
||||
.width(420)
|
||||
.padding(30)
|
||||
.width(380)
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -1463,51 +1444,50 @@ fn recents_card(state: &AppState) -> Element<'_, AppMessage> {
|
||||
}
|
||||
};
|
||||
|
||||
let empty = state.config.recents.is_empty();
|
||||
let content: Element<'_, AppMessage> = if empty {
|
||||
column![
|
||||
text("RECENT ROOMS").size(14).color(color_subtext),
|
||||
text("No recent rooms yet.").size(12).color(color_subtext),
|
||||
]
|
||||
.spacing(4)
|
||||
.into()
|
||||
} else {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let mut rows = column![].spacing(6).width(iced::Length::Fill);
|
||||
for r in &state.config.recents {
|
||||
let label = {
|
||||
let n = crate::sanitize::sanitize_name(&r.name);
|
||||
if n.is_empty() { "Untitled room".to_string() } else { n }
|
||||
};
|
||||
let when = crate::recents::relative_time(now, r.joined_at);
|
||||
let entry = button(
|
||||
row![
|
||||
text(label).size(14).color(color_text),
|
||||
horizontal_space(),
|
||||
text(when).size(11).color(color_subtext),
|
||||
]
|
||||
.align_y(iced::alignment::Vertical::Center),
|
||||
)
|
||||
.on_press(AppMessage::JoinRecent(r.ticket.clone()))
|
||||
.style(b_style(color_crust, color_surface, color_text, 6.0))
|
||||
.padding(8)
|
||||
.width(iced::Length::Fill);
|
||||
rows = rows.push(
|
||||
row![
|
||||
entry,
|
||||
button(text("✕").size(12))
|
||||
.on_press(AppMessage::RemoveRecent(r.ticket.clone()))
|
||||
.style(b_style(color_surface, color_maroon, color_text, 6.0))
|
||||
.padding(8),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::alignment::Vertical::Center),
|
||||
);
|
||||
}
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let mut rows = column![].spacing(6).width(iced::Length::Fill);
|
||||
if state.config.recents.is_empty() {
|
||||
rows = rows.push(
|
||||
text("No recent rooms yet — they'll appear here after you join one.")
|
||||
.size(12)
|
||||
.color(color_subtext),
|
||||
);
|
||||
}
|
||||
for r in &state.config.recents {
|
||||
let label = {
|
||||
let n = crate::sanitize::sanitize_name(&r.name);
|
||||
if n.is_empty() { "Untitled room".to_string() } else { n }
|
||||
};
|
||||
let when = crate::recents::relative_time(now, r.joined_at);
|
||||
let entry = button(
|
||||
row![
|
||||
text(label).size(14).color(color_text),
|
||||
horizontal_space(),
|
||||
text(when).size(11).color(color_subtext),
|
||||
]
|
||||
.align_y(iced::alignment::Vertical::Center),
|
||||
)
|
||||
.on_press(AppMessage::JoinRecent(r.ticket.clone()))
|
||||
.style(b_style(color_crust, color_surface, color_text, 6.0))
|
||||
.padding(8)
|
||||
.width(iced::Length::Fill);
|
||||
rows = rows.push(
|
||||
row![
|
||||
entry,
|
||||
button(text("✕").size(12))
|
||||
.on_press(AppMessage::RemoveRecent(r.ticket.clone()))
|
||||
.style(b_style(color_surface, color_maroon, color_text, 6.0))
|
||||
.padding(8),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::alignment::Vertical::Center),
|
||||
);
|
||||
}
|
||||
|
||||
container(
|
||||
column![
|
||||
text("RECENT ROOMS").size(18).color(color_text),
|
||||
text("Rooms you've been in — click to hop back. Best-effort: only works while someone's still there.")
|
||||
@@ -1516,14 +1496,11 @@ fn recents_card(state: &AppState) -> Element<'_, AppMessage> {
|
||||
vertical_space(10.0),
|
||||
rows,
|
||||
]
|
||||
.spacing(6)
|
||||
.into()
|
||||
};
|
||||
|
||||
container(content)
|
||||
.style(c_style(if empty { color_crust } else { color_mantle }, color_surface, 8.0))
|
||||
.padding(if empty { 16 } else { 24 })
|
||||
.width(if empty { 340 } else { 380 })
|
||||
.spacing(6),
|
||||
)
|
||||
.style(c_style(color_mantle, color_surface, 12.0))
|
||||
.padding(24)
|
||||
.width(380)
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -1539,7 +1516,6 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
|
||||
let color_red = pal.red;
|
||||
let color_maroon = pal.maroon;
|
||||
let color_green = pal.green;
|
||||
let has_friends = !state.friends.list().is_empty();
|
||||
|
||||
let c_style = move |bg: Color, b_color: Color, radius: f32| {
|
||||
move |_theme: &Theme| container::Style {
|
||||
@@ -1578,9 +1554,9 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
|
||||
|
||||
// The live friends list: status dot, inline rename, short id, remove.
|
||||
let mut friend_rows = column![].spacing(6).width(iced::Length::Fill);
|
||||
if !has_friends {
|
||||
if state.friends.list().is_empty() {
|
||||
friend_rows = friend_rows.push(
|
||||
text("No friends yet.")
|
||||
text("No friends yet — add one by their node ID below.")
|
||||
.size(12)
|
||||
.color(color_subtext),
|
||||
);
|
||||
@@ -1683,34 +1659,28 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
|
||||
]
|
||||
.spacing(4)
|
||||
.width(iced::Length::Fill);
|
||||
let intro: Element<'_, AppMessage> = if has_friends {
|
||||
text("Who's online — click Join to hop into a friend's room.")
|
||||
.size(11)
|
||||
.color(color_subtext)
|
||||
.into()
|
||||
} else {
|
||||
column![].into()
|
||||
};
|
||||
|
||||
container(
|
||||
column![
|
||||
text("FRIENDS").size(if has_friends { 18 } else { 14 }).color(color_text),
|
||||
intro,
|
||||
vertical_space(if has_friends { 10.0 } else { 4.0 }),
|
||||
text("FRIENDS").size(18).color(color_text),
|
||||
text("Who's online — click Join to hop into a friend's room.")
|
||||
.size(11)
|
||||
.color(color_subtext),
|
||||
vertical_space(10.0),
|
||||
readonly_warning,
|
||||
friend_rows,
|
||||
vertical_space(if has_friends { 12.0 } else { 8.0 }),
|
||||
vertical_space(12.0),
|
||||
text("Add a friend").size(13).color(color_subtext),
|
||||
add_form,
|
||||
vertical_space(if has_friends { 14.0 } else { 10.0 }),
|
||||
vertical_space(14.0),
|
||||
text("Your presence").size(13).color(color_subtext),
|
||||
presence_picker,
|
||||
]
|
||||
.spacing(6),
|
||||
)
|
||||
.style(c_style(color_mantle, color_surface, 12.0))
|
||||
.padding(if has_friends { 24 } else { 18 })
|
||||
.width(if has_friends { 460 } else { 360 })
|
||||
.padding(24)
|
||||
.width(460)
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -1780,9 +1750,8 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
}
|
||||
};
|
||||
|
||||
let layout_button: Element<'_, AppMessage> = if state.current_screen == Screen::Home {
|
||||
iced::widget::Space::new().width(0.0).height(0.0).into()
|
||||
} else {
|
||||
let top_bar = row![
|
||||
horizontal_space(),
|
||||
tooltip(
|
||||
button(
|
||||
Canvas::new(LayoutIcon { fg: color_text })
|
||||
@@ -1797,13 +1766,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.style(c_style(color_crust, color_surface, 6.0)),
|
||||
iced::widget::tooltip::Position::Bottom,
|
||||
)
|
||||
.gap(8)
|
||||
.into()
|
||||
};
|
||||
|
||||
let top_bar = row![
|
||||
horizontal_space(),
|
||||
layout_button,
|
||||
.gap(8),
|
||||
button(
|
||||
row![
|
||||
icon(IconKind::Settings, 15.0, color_text),
|
||||
@@ -2343,44 +2306,29 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
|
||||
if state.current_screen == Screen::Home {
|
||||
// --- HOME SCREEN ---
|
||||
// Keep Create/Join dominant on a fresh install. Once Recents or Friends
|
||||
// has real content, the wider three-card layout returns.
|
||||
let has_recents = !state.config.recents.is_empty();
|
||||
let has_friends = !state.friends.list().is_empty();
|
||||
// Two cards: Connect (left) + the live Friends list (right). They sit
|
||||
// side-by-side when the window is wide enough, and stack vertically on a
|
||||
// narrow window so the Friends card never gets crushed — below ~860px the
|
||||
// fixed-width Connect card would otherwise squeeze it until its node-ID
|
||||
// field and remove button clip away. `responsive` measures the available
|
||||
// width each layout pass and picks the orientation accordingly.
|
||||
// Three cards: Recents | Connect | Friends, side-by-side when there's room.
|
||||
// Three 380–460px cards need ~1280px to fit in a row, so below that the
|
||||
// `responsive` measure stacks them in a column (Connect first — the primary
|
||||
// action) rather than letting the row clip. Recents always shows (empty-
|
||||
// state hint when no history) for parity with the Friends card.
|
||||
let body = responsive(move |size| {
|
||||
let cards: Element<AppMessage> =
|
||||
match home_layout_mode(size.width, has_recents, has_friends) {
|
||||
HomeLayoutMode::FocusedEmpty => row![
|
||||
connect_card(state),
|
||||
column![friends_panel(state), recents_card(state)]
|
||||
.spacing(16)
|
||||
.width(iced::Length::Fixed(360.0)),
|
||||
]
|
||||
.spacing(22)
|
||||
.align_y(iced::alignment::Vertical::Top)
|
||||
.into(),
|
||||
HomeLayoutMode::ThreeColumn => row![
|
||||
recents_card(state),
|
||||
connect_card(state),
|
||||
friends_panel(state),
|
||||
]
|
||||
let cards: Element<AppMessage> = if size.width < 1280.0 {
|
||||
column![connect_card(state), recents_card(state), friends_panel(state)]
|
||||
.spacing(20)
|
||||
.align_x(iced::alignment::Horizontal::Center)
|
||||
.into()
|
||||
} else {
|
||||
row![recents_card(state), connect_card(state), friends_panel(state)]
|
||||
.spacing(20)
|
||||
.align_y(iced::alignment::Vertical::Top)
|
||||
.into(),
|
||||
HomeLayoutMode::Stacked => {
|
||||
let mut stack = column![connect_card(state)]
|
||||
.spacing(20)
|
||||
.align_x(iced::alignment::Horizontal::Center);
|
||||
if has_recents {
|
||||
stack = stack.push(recents_card(state));
|
||||
}
|
||||
stack = stack.push(friends_panel(state));
|
||||
if !has_recents {
|
||||
stack = stack.push(recents_card(state));
|
||||
}
|
||||
stack.into()
|
||||
}
|
||||
};
|
||||
.into()
|
||||
};
|
||||
scrollable(container(cards).center_x(iced::Length::Fill))
|
||||
.width(iced::Length::Fill)
|
||||
.into()
|
||||
@@ -4140,16 +4088,6 @@ mod tests {
|
||||
assert_eq!(format_duration(3661), "1:01:01");
|
||||
assert_eq!(format_duration(3725), "1:02:05");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_layout_prioritizes_connect_on_empty_home() {
|
||||
use super::{home_layout_mode, HomeLayoutMode};
|
||||
assert_eq!(home_layout_mode(1280.0, false, false), HomeLayoutMode::FocusedEmpty);
|
||||
assert_eq!(home_layout_mode(760.0, false, false), HomeLayoutMode::Stacked);
|
||||
assert_eq!(home_layout_mode(1280.0, true, false), HomeLayoutMode::ThreeColumn);
|
||||
assert_eq!(home_layout_mode(1100.0, true, true), HomeLayoutMode::Stacked);
|
||||
}
|
||||
|
||||
use super::{clamp_chat_height, clamp_participants_width, CHAT_MIN_H, PARTICIPANTS_MIN_W};
|
||||
|
||||
#[test]
|
||||
|
||||
+23
-6
@@ -79,6 +79,8 @@ enum GossipReject {
|
||||
BadSignature,
|
||||
/// Timestamp outside the freshness window — stale (replay) or implausibly future.
|
||||
OutOfWindow,
|
||||
/// A signed Announce advertised an address for a different node id.
|
||||
AnnounceAddressMismatch,
|
||||
}
|
||||
|
||||
/// Authenticate a received payload against the room topic and local clock. The
|
||||
@@ -99,6 +101,10 @@ fn verify_gossip(
|
||||
if now_ms.abs_diff(payload.ts) > window_ms {
|
||||
return Err(GossipReject::OutOfWindow);
|
||||
}
|
||||
if let GossipMessage::Announce(state) = &payload.msg
|
||||
&& state.addr.id != payload.author {
|
||||
return Err(GossipReject::AnnounceAddressMismatch);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -490,10 +496,8 @@ mod tests {
|
||||
use crate::network::PeerState;
|
||||
use iroh::SecretKey;
|
||||
|
||||
fn sample_peer_state() -> PeerState {
|
||||
let secret = SecretKey::generate();
|
||||
let public = secret.public();
|
||||
let addr = iroh::EndpointAddr::from(public);
|
||||
fn sample_peer_state_for(id: EndpointId) -> PeerState {
|
||||
let addr = iroh::EndpointAddr::from(id);
|
||||
PeerState {
|
||||
name: "TestPeerGossip".to_string(),
|
||||
is_muted: true,
|
||||
@@ -563,7 +567,7 @@ mod tests {
|
||||
fn test_gossip_payload_announce_round_trip() {
|
||||
let secret = SecretKey::generate();
|
||||
let topic = [9u8; 32];
|
||||
let peer_state = sample_peer_state();
|
||||
let peer_state = sample_peer_state_for(secret.public());
|
||||
let payload = sign_gossip(&secret, &topic, 1000, GossipMessage::Announce(peer_state.clone()));
|
||||
|
||||
let serialized = serde_json::to_string(&payload).unwrap();
|
||||
@@ -732,5 +736,18 @@ mod tests {
|
||||
// Within the window (clock skew tolerance) → accepted.
|
||||
assert!(verify_gossip(&p, &topic, 1_000_000 + GOSSIP_FRESHNESS_MS - 1, GOSSIP_FRESHNESS_MS).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_rejects_announce_with_address_for_another_identity() {
|
||||
let signer = SecretKey::generate();
|
||||
let advertised = SecretKey::generate();
|
||||
let topic = [6u8; 32];
|
||||
let state = sample_peer_state_for(advertised.public());
|
||||
let p = sign_gossip(&signer, &topic, 5_000, GossipMessage::Announce(state));
|
||||
|
||||
assert_eq!(
|
||||
verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS),
|
||||
Err(GossipReject::AnnounceAddressMismatch)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user