diff --git a/core/src/lib.rs b/core/src/lib.rs index fa4ba0c..426ec44 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -92,10 +92,9 @@ type Receipts = Arc>>; /// by `from`. Written by the RTC/LAN transports, read by `send` to decide whether /// the relay is still needed. type DirectSet = Arc>>; -/// 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>>; +/// 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>>; /// 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 { + 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) { if self.running.swap(true, Ordering::SeqCst) { diff --git a/core/src/rtc.rs b/core/src/rtc.rs index c2d06ca..8ba233f 100644 --- a/core/src/rtc.rs +++ b/core/src/rtc.rs @@ -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; diff --git a/ios/MacTetherApp/Sources/ContentView.swift b/ios/MacTetherApp/Sources/ContentView.swift index 5c9353c..1a9d37b 100644 --- a/ios/MacTetherApp/Sources/ContentView.swift +++ b/ios/MacTetherApp/Sources/ContentView.swift @@ -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 { diff --git a/ios/MacTetherApp/Sources/MacTetherStore.swift b/ios/MacTetherApp/Sources/MacTetherStore.swift index 53dd999..35f7135 100644 --- a/ios/MacTetherApp/Sources/MacTetherStore.swift +++ b/ios/MacTetherApp/Sources/MacTetherStore.swift @@ -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 " 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() } } diff --git a/ios/TetherApp/Sources/RoomView.swift b/ios/TetherApp/Sources/RoomView.swift index 94ef5f3..2915094 100644 --- a/ios/TetherApp/Sources/RoomView.swift +++ b/ios/TetherApp/Sources/RoomView.swift @@ -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 { diff --git a/ios/TetherApp/Sources/TetherStore.swift b/ios/TetherApp/Sources/TetherStore.swift index 5a2ebec..3db4920 100644 --- a/ios/TetherApp/Sources/TetherStore.swift +++ b/ios/TetherApp/Sources/TetherStore.swift @@ -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 " 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() } }