| 1 | import SwiftUI |
| 2 | |
| 3 | /// The same joining ceremony on Mac, iPhone and iPad. Authentication stays in the model. |
| 4 | @MainActor public struct CallsLobby: View { |
| 5 | @ObservedObject private var model: CallsModel |
| 6 | @Environment(\.colorScheme) private var colorScheme |
| 7 | @State private var joiningAsGuest: Bool |
| 8 | private let openSettings: (() -> Void)? |
| 9 | |
| 10 | /// `openSettings` is how the server line's Change button reaches the |
| 11 | /// server setting; on a Mac, where Settings is its own scene, it is left |
| 12 | /// nil and the button opens that scene. |
| 13 | public init(model: CallsModel, openSettings: (() -> Void)? = nil, guestSheet: Bool = false) { |
| 14 | self.model = model |
| 15 | self.openSettings = openSettings |
| 16 | _joiningAsGuest = State(initialValue: guestSheet) |
| 17 | } |
| 18 | |
| 19 | public var body: some View { |
| 20 | ScrollView { |
| 21 | VStack(alignment: .leading, spacing: 28) { |
| 22 | VStack(alignment: .leading, spacing: 16) { |
| 23 | Image(systemName: "video.fill") |
| 24 | .font(.largeTitle).foregroundStyle(.tint) |
| 25 | .padding(18).background(.tint.opacity(0.1), in: RoundedRectangle(cornerRadius: 22)) |
| 26 | .accessibilityHidden(true) |
| 27 | Text("OFICINA CALLS").font(.caption.bold()).tracking(2).foregroundStyle(.secondary) |
| 28 | Text(heading).font(.largeTitle.bold()).fixedSize(horizontal: false, vertical: true) |
| 29 | Text(subheading).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) |
| 30 | } |
| 31 | VStack(alignment: .leading, spacing: 20) { |
| 32 | if model.restoring { |
| 33 | ProgressView("Checking your sign-in") |
| 34 | } else if model.authenticating { |
| 35 | ProgressView("Waiting for browser confirmation") |
| 36 | if !model.confirmationCode.isEmpty { |
| 37 | VStack(alignment: .leading, spacing: 10) { |
| 38 | Text(model.confirmationCode).font(.title.monospaced().bold()) |
| 39 | .textSelection(.enabled) |
| 40 | Text( |
| 41 | model.handingOff |
| 42 | ? "Go back to your browser — the page you came from is asking you to confirm. Check it shows this same code." |
| 43 | : "Check that your browser shows this same code before confirming." |
| 44 | ) |
| 45 | .font(.callout).foregroundStyle(.secondary) |
| 46 | .fixedSize(horizontal: false, vertical: true) |
| 47 | }.frame(maxWidth: .infinity, alignment: .leading).padding(16) |
| 48 | .background(.quaternary, in: RoundedRectangle(cornerRadius: 12)) |
| 49 | } |
| 50 | if let url = model.signin { |
| 51 | Button( |
| 52 | model.handingOff ? "Confirm in another browser" : "Open browser again", |
| 53 | systemImage: "arrow.up.forward.app" |
| 54 | ) { |
| 55 | Task { _ = await model.openBrowser(url) } |
| 56 | }.buttonStyle(.borderedProminent) |
| 57 | } |
| 58 | Button("Cancel", action: model.cancelSignIn).buttonStyle(.bordered) |
| 59 | } else if !model.pendingLink.isEmpty { |
| 60 | pendingLink |
| 61 | } else if model.signedIn { |
| 62 | Label("Signed in to \(model.host)", systemImage: "person.crop.circle.badge.checkmark") |
| 63 | .font(.headline).textSelection(.enabled) |
| 64 | if !model.room.isEmpty { |
| 65 | Label("Meeting \(model.room)", systemImage: "video") |
| 66 | .font(.headline).textSelection(.enabled) |
| 67 | } |
| 68 | Label("Your camera and microphone will be requested when you join.", systemImage: "mic") |
| 69 | .font(.callout).foregroundStyle(.secondary) |
| 70 | // The meeting in hand comes first when there is one — the link |
| 71 | // someone signed in from, or the call they just left. Otherwise |
| 72 | // starting one is the primary act, however sign-in began. |
| 73 | if !model.room.isEmpty { |
| 74 | Button("Join meeting", systemImage: "video.fill", action: model.join) |
| 75 | .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) |
| 76 | .accessibilityIdentifier("joinMeeting") |
| 77 | } |
| 78 | if model.canStart { startButton } |
| 79 | if model.starting { ProgressView("Starting a meeting") } |
| 80 | if model.opening { ProgressView("Opening the meeting") } |
| 81 | // A link from someone else opens here with the same session: |
| 82 | // on this instance it joins straight away, and on another it is |
| 83 | // that instance's sign-in. |
| 84 | linkField |
| 85 | if model.visiting { |
| 86 | Text("Signed in for this meeting only. Your Calls server is still \(model.serverHost).") |
| 87 | .font(.callout).foregroundStyle(.secondary) |
| 88 | .fixedSize(horizontal: false, vertical: true) |
| 89 | } |
| 90 | if model.unchecked { |
| 91 | Button("Check sign-in again", systemImage: "arrow.clockwise", action: model.recheck) |
| 92 | .buttonStyle(.bordered) |
| 93 | } |
| 94 | Button("Sign out", systemImage: "rectangle.portrait.and.arrow.right", action: model.signOut) |
| 95 | .buttonStyle(.bordered).accessibilityIdentifier("signOut") |
| 96 | #if os(iOS) |
| 97 | Text("Keep Calls open during your call. Switching to another app ends the call.") |
| 98 | .font(.footnote).foregroundStyle(.secondary) |
| 99 | #endif |
| 100 | } else { |
| 101 | serverLine |
| 102 | Button(action: model.signIn) { |
| 103 | Label("Sign in", systemImage: "person.crop.circle") |
| 104 | .frame(maxWidth: .infinity) |
| 105 | } |
| 106 | .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) |
| 107 | .accessibilityIdentifier("signIn") |
| 108 | Button(action: { joiningAsGuest = true }) { |
| 109 | Label("Join a call as a guest", systemImage: "link").frame(maxWidth: .infinity) |
| 110 | } |
| 111 | .buttonStyle(.bordered).accessibilityIdentifier("joinAsGuest") |
| 112 | } |
| 113 | // The call just left, or ended, can still be reported for ten |
| 114 | // minutes (spec §15.26). |
| 115 | if model.call.reportableUntil != nil { |
| 116 | AfterCallReport(call: model.call) |
| 117 | } |
| 118 | if !model.error.isEmpty { |
| 119 | Label(model.error, systemImage: "exclamationmark.circle.fill") |
| 120 | .foregroundStyle(.red).fixedSize(horizontal: false, vertical: true) |
| 121 | .accessibilityLabel("Error: \(model.error)") |
| 122 | } |
| 123 | } |
| 124 | .controlSize(.large).frame(maxWidth: .infinity, alignment: .leading).padding(24) |
| 125 | .background( |
| 126 | colorScheme == .dark ? Color.white.opacity(0.045) : Color.white, |
| 127 | in: RoundedRectangle(cornerRadius: 20) |
| 128 | ) |
| 129 | .overlay { RoundedRectangle(cornerRadius: 20).strokeBorder(.primary.opacity(0.08)) } |
| 130 | // Before a call, signed in or not: "can they hear me?" is asked |
| 131 | // before joining, and needs no account to answer. |
| 132 | AudioSelfTestButton().buttonStyle(.bordered) |
| 133 | Label("A little closer, wherever you are.", systemImage: "sparkle") |
| 134 | .font(.callout).foregroundStyle(.secondary) |
| 135 | }.frame(maxWidth: 480, alignment: .leading).padding(32) |
| 136 | .frame(maxWidth: .infinity, minHeight: 540) |
| 137 | } |
| 138 | .background(CallsAppearance.background) |
| 139 | .tint(CallsAppearance.accent) |
| 140 | .sheet(isPresented: $joiningAsGuest) { |
| 141 | GuestJoinView(model: model) { joiningAsGuest = false } |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | /// Names the server the Sign in button signs in to, and where to change it. |
| 146 | @ViewBuilder private var serverLine: some View { |
| 147 | HStack(alignment: .firstTextBaseline, spacing: 8) { |
| 148 | Label { |
| 149 | Text("Your Calls server is **\(model.serverHost)**.") |
| 150 | .fixedSize(horizontal: false, vertical: true) |
| 151 | } icon: { |
| 152 | Image(systemName: "server.rack") |
| 153 | } |
| 154 | .foregroundStyle(.secondary) |
| 155 | Spacer(minLength: 0) |
| 156 | if let openSettings { |
| 157 | Button("Change", action: openSettings).buttonStyle(.borderless) |
| 158 | .accessibilityLabel("Change Calls server") |
| 159 | } else { |
| 160 | #if os(macOS) |
| 161 | SettingsLink { Text("Change") }.buttonStyle(.borderless) |
| 162 | .accessibilityLabel("Change Calls server") |
| 163 | #endif |
| 164 | } |
| 165 | } |
| 166 | .font(.callout) |
| 167 | } |
| 168 | |
| 169 | /// A meeting link that needs a sign-in first: offered, not started, so |
| 170 | /// the person sees which server it is on before a browser opens. |
| 171 | @ViewBuilder private var pendingLink: some View { |
| 172 | Label("A meeting on \(model.pendingHost)", systemImage: "video") |
| 173 | .font(.headline).textSelection(.enabled) |
| 174 | Text(pendingExplanation) |
| 175 | .font(.callout).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) |
| 176 | Button(action: model.signIn) { |
| 177 | Label("Sign in", systemImage: "person.crop.circle").frame(maxWidth: .infinity) |
| 178 | } |
| 179 | .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) |
| 180 | .accessibilityIdentifier("signIn") |
| 181 | Button("Not now", action: model.dismissPendingLink).buttonStyle(.bordered) |
| 182 | } |
| 183 | |
| 184 | /// Why a link needs a sign-in, in words that say what will happen. |
| 185 | private var pendingExplanation: String { |
| 186 | if model.pendingNoAccess { |
| 187 | return |
| 188 | "You’re signed in to \(model.pendingHost) in this app as someone who can’t join this meeting. Sign in for this meeting only — the app stays signed in as you are now." |
| 189 | } |
| 190 | if model.pendingOnServer { return "Sign in to join it." } |
| 191 | return |
| 192 | "This link is for a meeting on another Calls server, \(model.pendingHost). Sign in to it to join — just for this meeting. Your own server stays \(model.serverHost)." |
| 193 | } |
| 194 | |
| 195 | /// The signed-in home's address field: how a link someone sent is opened |
| 196 | /// without signing in again, so it stays second to the buttons above it. |
| 197 | @ViewBuilder private var linkField: some View { |
| 198 | VStack(alignment: .leading, spacing: 10) { |
| 199 | Text("Meeting link").font(.headline) |
| 200 | TextField("Paste a meeting link", text: $model.address) |
| 201 | .textFieldStyle(.roundedBorder) |
| 202 | .accessibilityLabel("Meeting link").accessibilityIdentifier("meetingLink") |
| 203 | #if os(iOS) |
| 204 | .keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled() |
| 205 | #endif |
| 206 | .onSubmit(model.connect) |
| 207 | } |
| 208 | let empty = model.address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty |
| 209 | // The default action belongs to the meeting buttons above it. |
| 210 | Button("Open link", systemImage: "link", action: model.connect) |
| 211 | .buttonStyle(.bordered).disabled(empty || model.opening) |
| 212 | } |
| 213 | |
| 214 | // Lifted out of the body because the type checker will not carry this |
| 215 | // many conditionals inside a ViewBuilder: the whole expression times |
| 216 | // out rather than failing on one line, which reads as a compiler |
| 217 | // problem and is a shape problem. |
| 218 | @ViewBuilder private var startButton: some View { |
| 219 | // Two branches rather than a ternary per modifier: buttonStyle's |
| 220 | // return type differs between the two, so the choice has to be made |
| 221 | // where whole views can differ and not inside one. |
| 222 | if model.room.isEmpty { |
| 223 | start("Start meeting").buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) |
| 224 | } else { |
| 225 | start("Start another meeting").buttonStyle(.bordered) |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | private func start(_ title: LocalizedStringKey) -> some View { |
| 230 | Button(title, systemImage: "video.badge.plus", action: model.startMeeting) |
| 231 | .accessibilityIdentifier("startMeeting") |
| 232 | .disabled(model.starting) |
| 233 | } |
| 234 | |
| 235 | private var heading: LocalizedStringKey { |
| 236 | if model.restoring { return "Welcome back." } |
| 237 | if model.authenticating { return "One more step." } |
| 238 | if !model.pendingLink.isEmpty { return "Join the meeting." } |
| 239 | if model.signedIn { return model.room.isEmpty ? "Ready when you are" : "Ready to join" } |
| 240 | return "Meet face to face." |
| 241 | } |
| 242 | |
| 243 | private var subheading: LocalizedStringKey { |
| 244 | if model.restoring { return "Checking that you’re still signed in." } |
| 245 | if model.authenticating { |
| 246 | if model.handingOff { |
| 247 | return "Confirm in your browser to join this meeting in the app as you. We’ll continue here automatically." |
| 248 | } |
| 249 | return |
| 250 | "Continue in your browser, then confirm that the code matches. We’ll continue here automatically." |
| 251 | } |
| 252 | if !model.pendingLink.isEmpty { return "Someone shared a meeting with you." } |
| 253 | if model.signedIn { |
| 254 | return model.room.isEmpty |
| 255 | ? "Start a meeting and share the link from inside it, or open a link you were sent." |
| 256 | : "Join when you’re ready." |
| 257 | } |
| 258 | return "A place for your team to come together." |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | /// "Report a problem" on the home screen after a call, while the server |
| 263 | /// still keeps that call's report token. Observes the call, so it goes |
| 264 | /// when the ten minutes are up. |
| 265 | struct AfterCallReport: View { |
| 266 | @ObservedObject var call: Call |
| 267 | var body: some View { |
| 268 | if call.reportableUntil != nil { |
| 269 | VStack(alignment: .leading, spacing: 6) { |
| 270 | ReportProblemButton { call.reportProblem() }.buttonStyle(.bordered) |
| 271 | Text("Something wrong with the call you were just in? You can report it for ten minutes after it ends.") |
| 272 | .font(.footnote).foregroundStyle(.secondary) |
| 273 | .fixedSize(horizontal: false, vertical: true) |
| 274 | } |
| 275 | .reportProblemSheet(call) |
| 276 | } |
| 277 | } |
| 278 | } |
| 279 | |