presence: "who's here" — online devices in the room, with names

Presence chirps now carry the friendly device name, and the engine tracks the
present set as Peers (id/name/source/room). New Engine.present() -> [Peer]
exposes who's online in the room across any network. Both apps poll it and show
a green-dot row of online devices above the feed.

The present set already powered prefer-direct's coverage check; this enriches it
with names and surfaces it. Verified live on macOS: an agent in the room shows
as "● Patrick's NAS".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 04:15:14 -05:00
parent ee938600c3
commit 88ae0e6958
6 changed files with 79 additions and 11 deletions

View File

@@ -92,10 +92,9 @@ type Receipts = Arc<Mutex<Vec<(Receipt, Instant)>>>;
/// by `from`. Written by the RTC/LAN transports, read by `send` to decide whether /// by `from`. Written by the RTC/LAN transports, read by `send` to decide whether
/// the relay is still needed. /// the relay is still needed.
type DirectSet = Arc<Mutex<HashSet<String>>>; type DirectSet = Arc<Mutex<HashSet<String>>>;
/// Peers currently in the room, by `from`, last-seen via presence chirps. Lets /// Peers currently in the room, by `from`, with their Peer info and last-seen
/// `send` know the full audience so it only suppresses the relay when *every* /// (via presence chirps). Drives both `send`'s coverage check and `present()`.
/// present peer is directly reachable. type PresentSet = Arc<Mutex<HashMap<String, (Peer, Instant)>>>;
type PresentSet = Arc<Mutex<HashMap<String, Instant>>>;
/// Short content fingerprint — a receipt carries this, never the clipboard text, /// Short content fingerprint — a receipt carries this, never the clipboard text,
/// so acks can't leak content (sender matches by computing the same fingerprint). /// so acks can't leak content (sender matches by computing the same fingerprint).
@@ -112,7 +111,7 @@ fn directly_covered(direct: &DirectSet, present: &PresentSet) -> bool {
let d = direct.lock().unwrap(); let d = direct.lock().unwrap();
let now = Instant::now(); let now = Instant::now();
let mut p = present.lock().unwrap(); let mut p = present.lock().unwrap();
p.retain(|_, t| now.duration_since(*t) < Duration::from_secs(20)); p.retain(|_, (_, t)| now.duration_since(*t) < Duration::from_secs(20));
!p.is_empty() && p.keys().all(|x| d.contains(x)) !p.is_empty() && p.keys().all(|x| d.contains(x))
} }
@@ -234,6 +233,15 @@ impl Engine {
map.values().map(|(p, _)| p.clone()).collect() map.values().map(|(p, _)| p.clone()).collect()
} }
/// Devices currently in your room (online via presence chirps), across any
/// network — for a "who's here" indicator. Pruned after 20s unseen.
pub fn present(&self) -> Vec<Peer> {
let now = Instant::now();
let mut p = self.present.lock().unwrap();
p.retain(|_, (_, t)| now.duration_since(*t) < Duration::from_secs(20));
p.values().map(|(peer, _)| peer.clone()).collect()
}
/// Begin streaming on every transport. Idempotent while already running. /// Begin streaming on every transport. Idempotent while already running.
pub fn start(&self, handler: Box<dyn MessageHandler>) { pub fn start(&self, handler: Box<dyn MessageHandler>) {
if self.running.swap(true, Ordering::SeqCst) { if self.running.swap(true, Ordering::SeqCst) {

View File

@@ -138,7 +138,9 @@ impl RtcTransport {
async fn post_presence(&self) { async fn post_presence(&self) {
self.post(json!({ self.post(json!({
"type": "presence", "role": self.cfg.source, "type": "presence", "role": self.cfg.source,
"from": self.cfg.from, "room": self.cfg.room, "from": self.cfg.from, "source": self.cfg.source,
"text": self.cfg.name, // friendly name for the "who's here" list
"room": self.cfg.room,
})) }))
.await; .await;
} }
@@ -415,11 +417,24 @@ impl RtcTransport {
} }
match v["type"].as_str().unwrap_or("") { match v["type"].as_str().unwrap_or("") {
"presence" => { "presence" => {
// Track who's in the room so send() knows the full audience. // Track who's in the room (with their name) for send()'s coverage
self.present // check and the engine's present() "who's here" list.
.lock() let name = v["text"].as_str().filter(|s| !s.is_empty()).unwrap_or(from);
.unwrap() let source = v["source"].as_str().unwrap_or("").to_string();
.insert(from.to_string(), Instant::now()); let room = v["room"].as_str().unwrap_or("").to_string();
self.present.lock().unwrap().insert(
from.to_string(),
(
crate::Peer {
id: from.to_string(),
name: name.to_string(),
source,
room,
account: String::new(),
},
Instant::now(),
),
);
// Deterministic initiator: the smaller id offers. // Deterministic initiator: the smaller id offers.
if self.cfg.from.as_str() < from { if self.cfg.from.as_str() < from {
self.initiate_offer(from).await; self.initiate_offer(from).await;

View File

@@ -8,6 +8,10 @@ struct ContentView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
toolbar toolbar
Divider() Divider()
if store.connected && !store.present.isEmpty {
presenceRow
Divider()
}
if store.connected && !store.nearby.isEmpty { if store.connected && !store.nearby.isEmpty {
nearbyStrip nearbyStrip
Divider() Divider()
@@ -38,6 +42,22 @@ struct ContentView: View {
.padding(.horizontal, 14).padding(.vertical, 5) .padding(.horizontal, 14).padding(.vertical, 5)
} }
// MARK: - Presence ("who's here")
private var presenceRow: some View {
HStack(spacing: 12) {
ForEach(store.present, id: \.id) { peer in
HStack(spacing: 5) {
Circle().fill(.green).frame(width: 7, height: 7)
Image(systemName: sourceIcon(peer.source)).font(.caption2)
Text(peer.name).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
}
Spacer()
}
.padding(.horizontal, 14).padding(.vertical, 5)
}
// MARK: - Nearby devices (AirDrop-style picker) // MARK: - Nearby devices (AirDrop-style picker)
private var nearbyStrip: some View { private var nearbyStrip: some View {

View File

@@ -26,6 +26,7 @@ final class MacTetherStore {
}() }()
var feed: [FeedItem] = [] var feed: [FeedItem] = []
var nearby: [Peer] = [] // tether devices on the LAN, for the picker var nearby: [Peer] = [] // tether devices on the LAN, for the picker
var present: [Peer] = [] // devices online in the room ("who's here")
var receipts: [Receipt] = [] // delivery acks "seen by <device>" var receipts: [Receipt] = [] // delivery acks "seen by <device>"
var connected = false var connected = false
var lastError: String? var lastError: String?
@@ -69,6 +70,7 @@ final class MacTetherStore {
try? await Task.sleep(for: .seconds(2)) try? await Task.sleep(for: .seconds(2))
guard let self, let engine = self.engine else { continue } guard let self, let engine = self.engine else { continue }
self.nearby = engine.nearby().sorted { $0.name < $1.name } self.nearby = engine.nearby().sorted { $0.name < $1.name }
self.present = engine.present().sorted { $0.name < $1.name }
self.receipts = engine.receipts() self.receipts = engine.receipts()
} }
} }

View File

@@ -9,6 +9,10 @@ struct RoomView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
connectionBar connectionBar
Divider() Divider()
if store.connected && !store.present.isEmpty {
presenceRow
Divider()
}
if store.connected && !store.nearby.isEmpty { if store.connected && !store.nearby.isEmpty {
nearbyStrip nearbyStrip
Divider() Divider()
@@ -58,6 +62,23 @@ struct RoomView: View {
.padding() .padding()
} }
// MARK: - Presence ("who's here")
private var presenceRow: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(store.present, id: \.id) { peer in
HStack(spacing: 5) {
Circle().fill(.green).frame(width: 7, height: 7)
Image(systemName: sourceIcon(peer.source)).font(.caption2)
Text(peer.name).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
}
}
.padding(.horizontal, 14).padding(.vertical, 5)
}
}
// MARK: - Nearby devices (AirDrop-style picker) // MARK: - Nearby devices (AirDrop-style picker)
private var nearbyStrip: some View { private var nearbyStrip: some View {

View File

@@ -31,6 +31,7 @@ final class TetherStore {
}() }()
var feed: [FeedItem] = [] var feed: [FeedItem] = []
var nearby: [Peer] = [] // tether devices on the LAN (empty on iOS w/o multicast entitlement) var nearby: [Peer] = [] // tether devices on the LAN (empty on iOS w/o multicast entitlement)
var present: [Peer] = [] // devices online in the room ("who's here")
var receipts: [Receipt] = [] // delivery acks "seen by <device>" var receipts: [Receipt] = [] // delivery acks "seen by <device>"
var connected = false var connected = false
var lastError: String? var lastError: String?
@@ -68,6 +69,7 @@ final class TetherStore {
try? await Task.sleep(for: .seconds(2)) try? await Task.sleep(for: .seconds(2))
guard let self, let engine = self.engine else { continue } guard let self, let engine = self.engine else { continue }
self.nearby = engine.nearby().sorted { $0.name < $1.name } self.nearby = engine.nearby().sorted { $0.name < $1.name }
self.present = engine.present().sorted { $0.name < $1.name }
self.receipts = engine.receipts() self.receipts = engine.receipts()
} }
} }