oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1import 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.
265struct 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