The repo never enforced rustfmt, so formatting had drifted broadly. This is a single mechanical `cargo fmt` pass over the whole crate (no behavioral change; lib suite green, 493 passed). Going forward fmt should be enforced (planned CI fmt --check step). Part of the 0.6.1 hygiene pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
184 lines
7.4 KiB
Rust
184 lines
7.4 KiB
Rust
//! Recently-joined rooms (W7 P5) — a small, purely-local, cosmetic convenience
|
||
//! list. Each entry remembers a gathering you were in: its label, the canonical
|
||
//! share ticket (a door you can try to re-enter through), and when you last
|
||
//! joined it. It is *not* a presence or reachability primitive — rejoining is
|
||
//! best-effort and only succeeds while someone is still in the room and reachable
|
||
//! through the stored ticket. Reliability across moves is P6 (discovery) and the
|
||
//! member-issued ticket floor, not this list.
|
||
//!
|
||
//! De-duplication is keyed on the room's `topic_id` (the stable room identity),
|
||
//! so re-joining the same gathering refreshes one entry instead of stacking
|
||
//! duplicates, even as the host address and label change between members/sessions.
|
||
|
||
use crate::network::PeerSpeakTicket;
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
/// How many recent rooms to keep. Oldest entries fall off past this.
|
||
pub const RECENTS_MAX: usize = 12;
|
||
|
||
/// One recently-joined room. Stored locally in `AppConfig`; never sent over the
|
||
/// wire. `ticket` is the canonical share ticket captured on join (carries the
|
||
/// topic + a member address + the label).
|
||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||
pub struct Recent {
|
||
/// Cosmetic room label at join time (already sanitized upstream). May be empty
|
||
/// for an unlabeled room — the UI shows a fallback then.
|
||
pub name: String,
|
||
/// The share ticket to attempt a rejoin through.
|
||
pub ticket: String,
|
||
/// Unix seconds when we last joined this room. Used for ordering + "x ago".
|
||
pub joined_at: u64,
|
||
}
|
||
|
||
/// The de-dup key for a recent: the room's `topic_id` when the ticket parses,
|
||
/// else the raw ticket string (so an unparseable entry still de-dups against an
|
||
/// identical one rather than stacking). Pure.
|
||
fn dedup_key(ticket: &str) -> Result<[u8; 32], &str> {
|
||
PeerSpeakTicket::topic_of(ticket).ok_or(ticket)
|
||
}
|
||
|
||
/// Whether two tickets refer to the same room (same `topic_id`, or — for
|
||
/// unparseable tickets — the same exact string).
|
||
fn same_room(a: &str, b: &str) -> bool {
|
||
dedup_key(a) == dedup_key(b)
|
||
}
|
||
|
||
/// Record a just-joined room at the front of `list` (most-recent-first).
|
||
///
|
||
/// If the room (by `topic_id`) is already present, its entry is refreshed —
|
||
/// the newest ticket, label, and timestamp win — and moved to the front rather
|
||
/// than duplicated. The list is then capped to [`RECENTS_MAX`]. Pure: the caller
|
||
/// supplies `now` (unix seconds) and persists the list afterwards.
|
||
pub fn push_recent(list: &mut Vec<Recent>, name: String, ticket: String, now: u64) {
|
||
list.retain(|r| !same_room(&r.ticket, &ticket));
|
||
list.insert(
|
||
0,
|
||
Recent {
|
||
name,
|
||
ticket,
|
||
joined_at: now,
|
||
},
|
||
);
|
||
list.truncate(RECENTS_MAX);
|
||
}
|
||
|
||
/// Drop the recent whose ticket refers to the same room as `ticket` (the × in
|
||
/// the UI). A no-op if no entry matches. Pure.
|
||
pub fn remove_recent(list: &mut Vec<Recent>, ticket: &str) {
|
||
list.retain(|r| !same_room(&r.ticket, ticket));
|
||
}
|
||
|
||
/// A short human label for how long ago `then` was, relative to `now` (both unix
|
||
/// seconds): "just now", "5m ago", "3h ago", "2d ago". Saturates at days. Pure;
|
||
/// `then > now` (clock skew) reads as "just now".
|
||
pub fn relative_time(now: u64, then: u64) -> String {
|
||
let secs = now.saturating_sub(then);
|
||
if secs < 60 {
|
||
"just now".to_string()
|
||
} else if secs < 3600 {
|
||
format!("{}m ago", secs / 60)
|
||
} else if secs < 86_400 {
|
||
format!("{}h ago", secs / 3600)
|
||
} else {
|
||
format!("{}d ago", secs / 86_400)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::network::PeerSpeakTicket;
|
||
use iroh::{EndpointAddr, SecretKey};
|
||
|
||
/// Build a real, parseable ticket for a fresh room with the given label.
|
||
fn ticket(name: &str, topic: [u8; 32]) -> String {
|
||
let host = SecretKey::generate().public();
|
||
PeerSpeakTicket {
|
||
host_addr: EndpointAddr::from(host),
|
||
topic_id: topic,
|
||
name: name.into(),
|
||
}
|
||
.to_string()
|
||
}
|
||
|
||
#[test]
|
||
fn push_prepends_and_orders_most_recent_first() {
|
||
let mut list = Vec::new();
|
||
push_recent(&mut list, "A".into(), ticket("A", [1; 32]), 100);
|
||
push_recent(&mut list, "B".into(), ticket("B", [2; 32]), 200);
|
||
assert_eq!(list.len(), 2);
|
||
assert_eq!(list[0].name, "B");
|
||
assert_eq!(list[1].name, "A");
|
||
}
|
||
|
||
#[test]
|
||
fn rejoining_same_topic_refreshes_not_duplicates() {
|
||
let mut list = Vec::new();
|
||
// Same room (topic [9;32]) joined twice, even via a different member's
|
||
// ticket and a renamed label, must collapse to one refreshed entry.
|
||
push_recent(&mut list, "HangOut".into(), ticket("HangOut", [9; 32]), 100);
|
||
push_recent(&mut list, "A".into(), ticket("A", [1; 32]), 150);
|
||
let newer = ticket("HangOut v2", [9; 32]);
|
||
push_recent(&mut list, "HangOut v2".into(), newer.clone(), 300);
|
||
assert_eq!(list.len(), 2);
|
||
// The refreshed room is now at the front with the new label + timestamp.
|
||
assert_eq!(list[0].name, "HangOut v2");
|
||
assert_eq!(list[0].ticket, newer);
|
||
assert_eq!(list[0].joined_at, 300);
|
||
}
|
||
|
||
#[test]
|
||
fn capped_at_recents_max_dropping_oldest() {
|
||
let mut list = Vec::new();
|
||
for i in 0..(RECENTS_MAX as u64 + 5) {
|
||
let mut topic = [0u8; 32];
|
||
topic[0] = i as u8;
|
||
push_recent(&mut list, format!("R{i}"), ticket("r", topic), i);
|
||
}
|
||
assert_eq!(list.len(), RECENTS_MAX);
|
||
// The newest is at the front; the oldest survivors fell off.
|
||
assert_eq!(list[0].name, format!("R{}", RECENTS_MAX as u64 + 4));
|
||
}
|
||
|
||
#[test]
|
||
fn remove_drops_matching_room_only() {
|
||
let mut list = Vec::new();
|
||
let keep = ticket("keep", [1; 32]);
|
||
let drop = ticket("drop", [2; 32]);
|
||
push_recent(&mut list, "keep".into(), keep.clone(), 100);
|
||
push_recent(&mut list, "drop".into(), drop.clone(), 200);
|
||
// Removing by a DIFFERENT member's ticket for the same room still matches.
|
||
let drop_other_member = ticket("drop", [2; 32]);
|
||
remove_recent(&mut list, &drop_other_member);
|
||
assert_eq!(list.len(), 1);
|
||
assert_eq!(list[0].name, "keep");
|
||
// Removing something not present is a no-op.
|
||
remove_recent(&mut list, &ticket("nope", [7; 32]));
|
||
assert_eq!(list.len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn unparseable_tickets_dedup_by_exact_string() {
|
||
let mut list = Vec::new();
|
||
push_recent(&mut list, "junk".into(), "not-a-ticket".into(), 10);
|
||
push_recent(&mut list, "junk-again".into(), "not-a-ticket".into(), 20);
|
||
// Same raw string → one entry, refreshed.
|
||
assert_eq!(list.len(), 1);
|
||
assert_eq!(list[0].joined_at, 20);
|
||
// A different unparseable string is a distinct entry.
|
||
push_recent(&mut list, "other".into(), "other-junk".into(), 30);
|
||
assert_eq!(list.len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn relative_time_buckets() {
|
||
assert_eq!(relative_time(1000, 1000), "just now");
|
||
assert_eq!(relative_time(1000, 970), "just now"); // < 60s
|
||
assert_eq!(relative_time(1000, 700), "5m ago");
|
||
assert_eq!(relative_time(100_000, 100_000 - 3 * 3600), "3h ago");
|
||
assert_eq!(relative_time(1_000_000, 1_000_000 - 2 * 86_400), "2d ago");
|
||
// Clock skew (then in the future) saturates to "just now", never panics.
|
||
assert_eq!(relative_time(100, 500), "just now");
|
||
}
|
||
}
|