import SwiftUI /// The same joining ceremony on Mac, iPhone and iPad. Authentication stays in the model. @MainActor public struct CallsLobby: View { @ObservedObject private var model: CallsModel @Environment(\.colorScheme) private var colorScheme @State private var joiningAsGuest: Bool private let openSettings: (() -> Void)? /// `openSettings` is how the server line's Change button reaches the /// server setting; on a Mac, where Settings is its own scene, it is left /// nil and the button opens that scene. public init(model: CallsModel, openSettings: (() -> Void)? = nil, guestSheet: Bool = false) { self.model = model self.openSettings = openSettings _joiningAsGuest = State(initialValue: guestSheet) } public var body: some View { ScrollView { VStack(alignment: .leading, spacing: 28) { VStack(alignment: .leading, spacing: 16) { Image(systemName: "video.fill") .font(.largeTitle).foregroundStyle(.tint) .padding(18).background(.tint.opacity(0.1), in: RoundedRectangle(cornerRadius: 22)) .accessibilityHidden(true) Text("OFICINA CALLS").font(.caption.bold()).tracking(2).foregroundStyle(.secondary) Text(heading).font(.largeTitle.bold()).fixedSize(horizontal: false, vertical: true) Text(subheading).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) } VStack(alignment: .leading, spacing: 20) { if model.restoring { ProgressView("Checking your sign-in") } else if model.authenticating { ProgressView("Waiting for browser confirmation") if !model.confirmationCode.isEmpty { VStack(alignment: .leading, spacing: 10) { Text(model.confirmationCode).font(.title.monospaced().bold()) .textSelection(.enabled) Text( model.handingOff ? "Go back to your browser — the page you came from is asking you to confirm. Check it shows this same code." : "Check that your browser shows this same code before confirming." ) .font(.callout).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) }.frame(maxWidth: .infinity, alignment: .leading).padding(16) .background(.quaternary, in: RoundedRectangle(cornerRadius: 12)) } if let url = model.signin { Button( model.handingOff ? "Confirm in another browser" : "Open browser again", systemImage: "arrow.up.forward.app" ) { Task { _ = await model.openBrowser(url) } }.buttonStyle(.borderedProminent) } Button("Cancel", action: model.cancelSignIn).buttonStyle(.bordered) } else if !model.pendingLink.isEmpty { pendingLink } else if model.signedIn { Label("Signed in to \(model.host)", systemImage: "person.crop.circle.badge.checkmark") .font(.headline).textSelection(.enabled) if !model.room.isEmpty { Label("Meeting \(model.room)", systemImage: "video") .font(.headline).textSelection(.enabled) } Label("Your camera and microphone will be requested when you join.", systemImage: "mic") .font(.callout).foregroundStyle(.secondary) // The meeting in hand comes first when there is one — the link // someone signed in from, or the call they just left. Otherwise // starting one is the primary act, however sign-in began. if !model.room.isEmpty { Button("Join meeting", systemImage: "video.fill", action: model.join) .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) .accessibilityIdentifier("joinMeeting") } if model.canStart { startButton } if model.starting { ProgressView("Starting a meeting") } if model.opening { ProgressView("Opening the meeting") } // A link from someone else opens here with the same session: // on this instance it joins straight away, and on another it is // that instance's sign-in. linkField if model.visiting { Text("Signed in for this meeting only. Your Calls server is still \(model.serverHost).") .font(.callout).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } if model.unchecked { Button("Check sign-in again", systemImage: "arrow.clockwise", action: model.recheck) .buttonStyle(.bordered) } Button("Sign out", systemImage: "rectangle.portrait.and.arrow.right", action: model.signOut) .buttonStyle(.bordered).accessibilityIdentifier("signOut") #if os(iOS) Text("Keep Calls open during your call. Switching to another app ends the call.") .font(.footnote).foregroundStyle(.secondary) #endif } else { serverLine Button(action: model.signIn) { Label("Sign in", systemImage: "person.crop.circle") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) .accessibilityIdentifier("signIn") Button(action: { joiningAsGuest = true }) { Label("Join a call as a guest", systemImage: "link").frame(maxWidth: .infinity) } .buttonStyle(.bordered).accessibilityIdentifier("joinAsGuest") } // The call just left, or ended, can still be reported for ten // minutes (spec §15.26). if model.call.reportableUntil != nil { AfterCallReport(call: model.call) } if !model.error.isEmpty { Label(model.error, systemImage: "exclamationmark.circle.fill") .foregroundStyle(.red).fixedSize(horizontal: false, vertical: true) .accessibilityLabel("Error: \(model.error)") } } .controlSize(.large).frame(maxWidth: .infinity, alignment: .leading).padding(24) .background( colorScheme == .dark ? Color.white.opacity(0.045) : Color.white, in: RoundedRectangle(cornerRadius: 20) ) .overlay { RoundedRectangle(cornerRadius: 20).strokeBorder(.primary.opacity(0.08)) } // Before a call, signed in or not: "can they hear me?" is asked // before joining, and needs no account to answer. AudioSelfTestButton().buttonStyle(.bordered) Label("A little closer, wherever you are.", systemImage: "sparkle") .font(.callout).foregroundStyle(.secondary) }.frame(maxWidth: 480, alignment: .leading).padding(32) .frame(maxWidth: .infinity, minHeight: 540) } .background(CallsAppearance.background) .tint(CallsAppearance.accent) .sheet(isPresented: $joiningAsGuest) { GuestJoinView(model: model) { joiningAsGuest = false } } } /// Names the server the Sign in button signs in to, and where to change it. @ViewBuilder private var serverLine: some View { HStack(alignment: .firstTextBaseline, spacing: 8) { Label { Text("Your Calls server is **\(model.serverHost)**.") .fixedSize(horizontal: false, vertical: true) } icon: { Image(systemName: "server.rack") } .foregroundStyle(.secondary) Spacer(minLength: 0) if let openSettings { Button("Change", action: openSettings).buttonStyle(.borderless) .accessibilityLabel("Change Calls server") } else { #if os(macOS) SettingsLink { Text("Change") }.buttonStyle(.borderless) .accessibilityLabel("Change Calls server") #endif } } .font(.callout) } /// A meeting link that needs a sign-in first: offered, not started, so /// the person sees which server it is on before a browser opens. @ViewBuilder private var pendingLink: some View { Label("A meeting on \(model.pendingHost)", systemImage: "video") .font(.headline).textSelection(.enabled) Text(pendingExplanation) .font(.callout).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) Button(action: model.signIn) { Label("Sign in", systemImage: "person.crop.circle").frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) .accessibilityIdentifier("signIn") Button("Not now", action: model.dismissPendingLink).buttonStyle(.bordered) } /// Why a link needs a sign-in, in words that say what will happen. private var pendingExplanation: String { if model.pendingNoAccess { return "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." } if model.pendingOnServer { return "Sign in to join it." } return "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)." } /// The signed-in home's address field: how a link someone sent is opened /// without signing in again, so it stays second to the buttons above it. @ViewBuilder private var linkField: some View { VStack(alignment: .leading, spacing: 10) { Text("Meeting link").font(.headline) TextField("Paste a meeting link", text: $model.address) .textFieldStyle(.roundedBorder) .accessibilityLabel("Meeting link").accessibilityIdentifier("meetingLink") #if os(iOS) .keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled() #endif .onSubmit(model.connect) } let empty = model.address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty // The default action belongs to the meeting buttons above it. Button("Open link", systemImage: "link", action: model.connect) .buttonStyle(.bordered).disabled(empty || model.opening) } // Lifted out of the body because the type checker will not carry this // many conditionals inside a ViewBuilder: the whole expression times // out rather than failing on one line, which reads as a compiler // problem and is a shape problem. @ViewBuilder private var startButton: some View { // Two branches rather than a ternary per modifier: buttonStyle's // return type differs between the two, so the choice has to be made // where whole views can differ and not inside one. if model.room.isEmpty { start("Start meeting").buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) } else { start("Start another meeting").buttonStyle(.bordered) } } private func start(_ title: LocalizedStringKey) -> some View { Button(title, systemImage: "video.badge.plus", action: model.startMeeting) .accessibilityIdentifier("startMeeting") .disabled(model.starting) } private var heading: LocalizedStringKey { if model.restoring { return "Welcome back." } if model.authenticating { return "One more step." } if !model.pendingLink.isEmpty { return "Join the meeting." } if model.signedIn { return model.room.isEmpty ? "Ready when you are" : "Ready to join" } return "Meet face to face." } private var subheading: LocalizedStringKey { if model.restoring { return "Checking that you’re still signed in." } if model.authenticating { if model.handingOff { return "Confirm in your browser to join this meeting in the app as you. We’ll continue here automatically." } return "Continue in your browser, then confirm that the code matches. We’ll continue here automatically." } if !model.pendingLink.isEmpty { return "Someone shared a meeting with you." } if model.signedIn { return model.room.isEmpty ? "Start a meeting and share the link from inside it, or open a link you were sent." : "Join when you’re ready." } return "A place for your team to come together." } } /// "Report a problem" on the home screen after a call, while the server /// still keeps that call's report token. Observes the call, so it goes /// when the ten minutes are up. struct AfterCallReport: View { @ObservedObject var call: Call var body: some View { if call.reportableUntil != nil { VStack(alignment: .leading, spacing: 6) { ReportProblemButton { call.reportProblem() }.buttonStyle(.bordered) Text("Something wrong with the call you were just in? You can report it for ten minutes after it ends.") .font(.footnote).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } .reportProblemSheet(call) } } }