Files
tether/ios/TetherApp/Sources/RoomView.swift
Patrick Ecord 88ae0e6958 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>
2026-06-21 04:15:14 -05:00

179 lines
6.5 KiB
Swift

import SwiftUI
struct RoomView: View {
@Bindable var store: TetherStore
@State private var draft = ""
var body: some View {
NavigationStack {
VStack(spacing: 0) {
connectionBar
Divider()
if store.connected && !store.present.isEmpty {
presenceRow
Divider()
}
if store.connected && !store.nearby.isEmpty {
nearbyStrip
Divider()
}
feedList
Divider()
if !store.receipts.isEmpty {
seenByBar
Divider()
}
composer
}
.navigationTitle("tether")
.navigationBarTitleDisplayMode(.inline)
.task { store.connect() } // auto-connect on launch (defaults are production-ready)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Circle()
.fill(store.connected ? .green : .secondary)
.frame(width: 10, height: 10)
}
}
}
}
private var connectionBar: some View {
VStack(spacing: 8) {
TextField("https://tether.pecord.io", text: $store.serverURL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
HStack {
TextField("room id (8 hex)", text: $store.room)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button(store.connected ? "Disconnect" : "Connect") {
store.connected ? store.disconnect() : store.connect()
}
.buttonStyle(.borderedProminent)
}
if let err = store.lastError {
Text(err).font(.caption).foregroundStyle(.red)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.textFieldStyle(.roundedBorder)
.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 {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
Label("Nearby", systemImage: "dot.radiowaves.left.and.right")
.font(.caption).foregroundStyle(.secondary)
ForEach(store.nearby, id: \.id) { peer in
let here = peer.room == store.room
Button {
store.pair(with: peer)
} label: {
HStack(spacing: 6) {
Image(systemName: sourceIcon(peer.source))
VStack(alignment: .leading, spacing: 1) {
Text(peer.name).font(.caption).lineLimit(1)
Text(here ? "in your room" : "tap to join")
.font(.caption2)
.foregroundStyle(here ? .green : .secondary)
}
}
.padding(.horizontal, 10).padding(.vertical, 6)
.background(Color.primary.opacity(0.06), in: Capsule())
}
.buttonStyle(.plain)
.disabled(here)
}
}
.padding(.horizontal, 12).padding(.vertical, 6)
}
}
private var seenByBar: some View {
let names = Array(Set(store.receipts.map(\.name))).sorted()
return HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green).font(.caption)
Text("Seen by \(names.joined(separator: ", "))")
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
Spacer()
}
.padding(.horizontal, 14).padding(.vertical, 5)
}
private func sourceIcon(_ source: String) -> String {
if source.contains("iphone") || source.contains("ios") { return "iphone" }
if source.contains("ipad") { return "ipad" }
if source.contains("mac") { return "laptopcomputer" }
if source.contains("windows") { return "pc" }
return "desktopcomputer"
}
private var feedList: some View {
List(store.feed) { msg in
VStack(alignment: .leading, spacing: 4) {
Text(msg.text ?? "")
.lineLimit(4)
HStack {
Text(msg.source ?? "?").font(.caption2)
Spacer()
Text(msg.date, style: .time).font(.caption2)
}
.foregroundStyle(.secondary)
}
.contentShape(Rectangle())
.onTapGesture { store.copyToPasteboard(msg) }
.swipeActions {
Button("Copy") { store.copyToPasteboard(msg) }.tint(.blue)
}
}
.listStyle(.plain)
.overlay {
if store.feed.isEmpty {
ContentUnavailableView("No messages yet",
systemImage: "doc.on.clipboard",
description: Text("Connect, then send from any device in the room."))
}
}
}
private var composer: some View {
HStack {
TextField("Type or paste…", text: $draft, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1...4)
Button {
if draft.isEmpty { store.sendClipboard() }
else { store.send(draft); draft = "" }
} label: {
Image(systemName: draft.isEmpty ? "doc.on.clipboard" : "paperplane.fill")
.font(.title2)
}
.disabled(!store.connected)
}
.padding()
}
}