| 1 | import Foundation |
| 2 | import UserNotifications |
| 3 | |
| 4 | #if os(iOS) |
| 5 | import UIKit |
| 6 | #else |
| 7 | import AppKit |
| 8 | #endif |
| 9 | |
| 10 | /// Notices when someone new is waiting to be let in, and says so once. |
| 11 | /// |
| 12 | /// The admission queue is already on screen for a member who is looking at |
| 13 | /// the call. This is for the member who is not: another Space, another |
| 14 | /// app, a phone in a pocket. Without it, someone knocking on a call whose |
| 15 | /// host has tabbed away waits until the host happens to look. |
| 16 | /// |
| 17 | /// The decision of *whether* to announce is kept apart from the act of |
| 18 | /// announcing, because the decision is the part with rules in it — once |
| 19 | /// per person, never for someone already announced, forgotten when they |
| 20 | /// stop waiting so a re-knock is heard again — and the act needs a |
| 21 | /// notification centre and a real bundle. |
| 22 | @MainActor public final class KnockWatch { |
| 23 | /// Peers already announced and still waiting. Kept rather than a plain |
| 24 | /// count so that one person leaving the queue and another arriving is |
| 25 | /// one announcement and not none. |
| 26 | private(set) var announced: Set<String> = [] |
| 27 | |
| 28 | public init() {} |
| 29 | |
| 30 | /// Returns the people worth announcing, and records them. |
| 31 | /// |
| 32 | /// `visible` is the whole of the "don't shout at someone already |
| 33 | /// looking" rule: when the queue is on screen, arrivals are recorded as |
| 34 | /// though announced, so tabbing away afterwards does not then produce a |
| 35 | /// pile of notifications about people who have been waiting for a |
| 36 | /// while. Only someone who arrives *while* you are away is news. |
| 37 | public func arrivals(waiting: [RosterPeer], visible: Bool) -> [RosterPeer] { |
| 38 | let present = Set(waiting.map(\.peer)) |
| 39 | // Someone who left the queue is forgotten, so that knocking again |
| 40 | // after being let in — or after giving up — is heard again. |
| 41 | announced.formIntersection(present) |
| 42 | let fresh = waiting.filter { !announced.contains($0.peer) } |
| 43 | announced.formUnion(present) |
| 44 | return visible ? [] : fresh |
| 45 | } |
| 46 | |
| 47 | /// Dropped when a call ends: a queue from a finished meeting has |
| 48 | /// nothing to say about the next one. |
| 49 | public func reset() { announced = [] } |
| 50 | } |
| 51 | |
| 52 | /// The act, as opposed to the decision. Local notifications only: there |
| 53 | /// is no APNs anywhere in this suite — no push key, no device tokens, no |
| 54 | /// sender — so this reaches a member whose app is running and not |
| 55 | /// frontmost, and not one whose app is closed. That second case is real |
| 56 | /// and is not solved here. |
| 57 | @MainActor public enum KnockNotice { |
| 58 | /// True when the app is in front, and the queue therefore already |
| 59 | /// visible to anyone looking at it. |
| 60 | public static var appIsFrontmost: Bool { |
| 61 | #if os(iOS) |
| 62 | return UIApplication.shared.applicationState == .active |
| 63 | #else |
| 64 | return NSApp?.isActive ?? false |
| 65 | #endif |
| 66 | } |
| 67 | |
| 68 | /// Asks once, at join, rather than at the first knock: a permission |
| 69 | /// sheet arriving at the same moment as the thing it is about is a |
| 70 | /// sheet that gets dismissed to get at the thing. |
| 71 | public static func requestPermission() { |
| 72 | guard let centre = centre else { return } |
| 73 | centre.requestAuthorization(options: [.alert, .sound]) { _, _ in } |
| 74 | } |
| 75 | |
| 76 | public static func announce(_ people: [RosterPeer]) { |
| 77 | guard let centre = centre, !people.isEmpty else { return } |
| 78 | for person in people { |
| 79 | let content = UNMutableNotificationContent() |
| 80 | content.title = "Someone is waiting to join" |
| 81 | // The person's own name, which came from the server's roster and is |
| 82 | // someone else's text — so it goes in the body as data and never |
| 83 | // into a format string that could be read as anything else. |
| 84 | content.body = person.name |
| 85 | content.sound = .default |
| 86 | centre.add( |
| 87 | UNNotificationRequest( |
| 88 | identifier: "knock-" + person.peer, content: content, trigger: nil)) |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | /// Nil outside a real application bundle. UNUserNotificationCenter |
| 93 | /// raises rather than returning an error when there is no bundle |
| 94 | /// identifier to register against, which is every unit test and the |
| 95 | /// interop executable — neither of which should take the app down for |
| 96 | /// want of a notification nobody would see. |
| 97 | private static var centre: UNUserNotificationCenter? { |
| 98 | guard Bundle.main.bundleIdentifier != nil else { return nil } |
| 99 | return UNUserNotificationCenter.current() |
| 100 | } |
| 101 | } |
| 102 | |