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
/// the relay is still needed.
type DirectSet = Arc<Mutex<HashSet<String>>>;
/// Peers currently in the room, by `from`, last-seen via presence chirps. Lets
/// `send` know the full audience so it only suppresses the relay when *every*
/// present peer is directly reachable.
type PresentSet = Arc<Mutex<HashMap<String, Instant>>>;
/// Peers currently in the room, by `from`, with their Peer info and last-seen
/// (via presence chirps). Drives both `send`'s coverage check and `present()`.
type PresentSet = Arc<Mutex<HashMap<String, (Peer, Instant)>>>;
/// Short content fingerprint — a receipt carries this, never the clipboard text,
/// 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 now = Instant::now();
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))
}
@@ -234,6 +233,15 @@ impl Engine {
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.
pub fn start(&self, handler: Box<dyn MessageHandler>) {
if self.running.swap(true, Ordering::SeqCst) {

View File

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

View File

@@ -8,6 +8,10 @@ struct ContentView: View {
VStack(spacing: 0) {
toolbar
Divider()
if store.connected && !store.present.isEmpty {
presenceRow
Divider()
}
if store.connected && !store.nearby.isEmpty {
nearbyStrip
Divider()
@@ -38,6 +42,22 @@ struct ContentView: View {
.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)
private var nearbyStrip: some View {

View File

@@ -26,6 +26,7 @@ final class MacTetherStore {
}()
var feed: [FeedItem] = []
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 connected = false
var lastError: String?
@@ -69,6 +70,7 @@ final class MacTetherStore {
try? await Task.sleep(for: .seconds(2))
guard let self, let engine = self.engine else { continue }
self.nearby = engine.nearby().sorted { $0.name < $1.name }
self.present = engine.present().sorted { $0.name < $1.name }
self.receipts = engine.receipts()
}
}

View File

@@ -9,6 +9,10 @@ struct RoomView: View {
VStack(spacing: 0) {
connectionBar
Divider()
if store.connected && !store.present.isEmpty {
presenceRow
Divider()
}
if store.connected && !store.nearby.isEmpty {
nearbyStrip
Divider()
@@ -58,6 +62,23 @@ struct RoomView: View {
.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)
private var nearbyStrip: some View {

View File

@@ -31,6 +31,7 @@ final class TetherStore {
}()
var feed: [FeedItem] = []
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 connected = false
var lastError: String?
@@ -68,6 +69,7 @@ final class TetherStore {
try? await Task.sleep(for: .seconds(2))
guard let self, let engine = self.engine else { continue }
self.nearby = engine.nearby().sorted { $0.name < $1.name }
self.present = engine.present().sorted { $0.name < $1.name }
self.receipts = engine.receipts()
}
}