import Foundation import UserNotifications #if os(iOS) import UIKit #else import AppKit #endif /// Notices when someone new is waiting to be let in, and says so once. /// /// The admission queue is already on screen for a member who is looking at /// the call. This is for the member who is not: another Space, another /// app, a phone in a pocket. Without it, someone knocking on a call whose /// host has tabbed away waits until the host happens to look. /// /// The decision of *whether* to announce is kept apart from the act of /// announcing, because the decision is the part with rules in it — once /// per person, never for someone already announced, forgotten when they /// stop waiting so a re-knock is heard again — and the act needs a /// notification centre and a real bundle. @MainActor public final class KnockWatch { /// Peers already announced and still waiting. Kept rather than a plain /// count so that one person leaving the queue and another arriving is /// one announcement and not none. private(set) var announced: Set = [] public init() {} /// Returns the people worth announcing, and records them. /// /// `visible` is the whole of the "don't shout at someone already /// looking" rule: when the queue is on screen, arrivals are recorded as /// though announced, so tabbing away afterwards does not then produce a /// pile of notifications about people who have been waiting for a /// while. Only someone who arrives *while* you are away is news. public func arrivals(waiting: [RosterPeer], visible: Bool) -> [RosterPeer] { let present = Set(waiting.map(\.peer)) // Someone who left the queue is forgotten, so that knocking again // after being let in — or after giving up — is heard again. announced.formIntersection(present) let fresh = waiting.filter { !announced.contains($0.peer) } announced.formUnion(present) return visible ? [] : fresh } /// Dropped when a call ends: a queue from a finished meeting has /// nothing to say about the next one. public func reset() { announced = [] } } /// The act, as opposed to the decision. Local notifications only: there /// is no APNs anywhere in this suite — no push key, no device tokens, no /// sender — so this reaches a member whose app is running and not /// frontmost, and not one whose app is closed. That second case is real /// and is not solved here. @MainActor public enum KnockNotice { /// True when the app is in front, and the queue therefore already /// visible to anyone looking at it. public static var appIsFrontmost: Bool { #if os(iOS) return UIApplication.shared.applicationState == .active #else return NSApp?.isActive ?? false #endif } /// Asks once, at join, rather than at the first knock: a permission /// sheet arriving at the same moment as the thing it is about is a /// sheet that gets dismissed to get at the thing. public static func requestPermission() { guard let centre = centre else { return } centre.requestAuthorization(options: [.alert, .sound]) { _, _ in } } public static func announce(_ people: [RosterPeer]) { guard let centre = centre, !people.isEmpty else { return } for person in people { let content = UNMutableNotificationContent() content.title = "Someone is waiting to join" // The person's own name, which came from the server's roster and is // someone else's text — so it goes in the body as data and never // into a format string that could be read as anything else. content.body = person.name content.sound = .default centre.add( UNNotificationRequest( identifier: "knock-" + person.peer, content: content, trigger: nil)) } } /// Nil outside a real application bundle. UNUserNotificationCenter /// raises rather than returning an error when there is no bundle /// identifier to register against, which is every unit test and the /// interop executable — neither of which should take the app down for /// want of a notification nobody would see. private static var centre: UNUserNotificationCenter? { guard Bundle.main.bundleIdentifier != nil else { return nil } return UNUserNotificationCenter.current() } }