Files
peerspeak/src/recents.rs
T
molluskandClaude Opus 4.8 d0a16cb8b9 style: apply cargo fmt across the crate (A20)
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>
2026-06-29 02:11:44 -04:00

184 lines
7.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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");
}
}