oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1#if os(macOS)
2 import AppKit
3 import SwiftUI
4
5 public struct MacCallView: View {
6 @ObservedObject var call: Call
7 let leave: () -> Void
8 let rejoin: (() -> Void)?
9 @ObservedObject var arrangement: CallArrangement
10 @ObservedObject var speakerNow: SpeakerNow
11 @State var showingChat: Bool
12 /// A tile being dragged to a new place, and where every tile is.
13 @State var drag: TileDrag?
14 @State var tileFrames: [String: CGRect] = [:]
15 @State var readChatCount = 0
16 @State var showingDevices: Bool
17 @State var showingLayout = false
18 @State var showingInvitation = false
19 @State var showingMore = false
20 @State var devicesHeight: CGFloat = 360
21 @StateObject var chatWindow = MacChatWindow()
22 @StateObject var chrome: ControlsVisibilityModel
23 @AppStorage(CallLayoutStyle.key) private var layoutChoice = CallLayoutStyle.defaultStyle.rawValue
24 @AppStorage(PiPCorner.key) var pipCorner = PiPCorner.bottomTrailing.rawValue
25 @State var pipDrag: CGSize = .zero
26 @State var menuTracking = false
27 @State var sheetShowing = false
28 @State var dismissedNotices: Set<String> = []
29 @State var barFrame: CGRect = .zero
30 @FocusState var focusedControl: CallControlID?
31 @Environment(\.accessibilityReduceMotion) var reduceMotion
32 @Environment(\.accessibilityVoiceOverEnabled) var voiceOver
33 @Environment(\.fixtureBackdrop) var fixtureBackdrop
34 private let style: CallLayoutStyle?
35
36 /// - Parameters:
37 /// - grid: Grid (true) or Speaker (false) for this call, when not
38 /// the person's own choice (fixtures).
39 /// - style: the layout, when not the person's own choice (fixtures).
40 /// - controlsShown: pins the full-window controls shown or hidden
41 /// (fixtures); nil lets the pointer decide.
42 public init(
43 call: Call, grid: Bool? = nil, showingChat: Bool = false, showingDevices: Bool = false,
44 style: CallLayoutStyle? = nil, controlsShown: Bool? = nil, leave: @escaping () -> Void,
45 rejoin: (() -> Void)? = nil
46 ) {
47 self.call = call
48 self.leave = leave
49 self.rejoin = rejoin
50 self.style = style
51 if let grid { call.arrangement.show(grid ? .grid : .speaker) }
52 arrangement = call.arrangement
53 speakerNow = call.levels.speaker
54 _showingChat = State(initialValue: showingChat)
55 _showingDevices = State(initialValue: showingDevices)
56 _chrome = StateObject(wrappedValue: ControlsVisibilityModel(input: .pointer, pinned: controlsShown))
57 }
58
59 var layout: CallLayoutStyle { style ?? CallLayoutStyle(rawValue: layoutChoice) ?? .defaultStyle }
60
61 public var body: some View {
62 Group {
63 if layout == .classic {
64 classic.padding(24)
65 } else {
66 immersive
67 }
68 }
69 .overlay(alignment: .bottom) {
70 CallToast(call: call).padding(.bottom, layout == .classic ? 100 : Self.barReserve + 16)
71 }
72 .onAppear {
73 if showingChat { readChatCount = call.receivedChatCount }
74 }
75 .task { await call.watchAudio() }
76 // Space mutes and unmutes, unless a text field or a control has the
77 // keyboard. The Call menu's ⌘⇧M is the same toggle, for when it has.
78 .spaceToMute(call)
79 .onChange(of: call.receivedChatCount) { _, count in
80 if showingChat || chatWindow.isOpen { readChatCount = count }
81 }
82 .onChange(of: showingChat) { _, visible in
83 if visible { readChatCount = call.receivedChatCount }
84 }
85 .onChange(of: chatWindow.isOpen) { _, visible in
86 if visible { readChatCount = call.receivedChatCount }
87 }
88 .onDisappear { chatWindow.close() }
89 .reportProblemSheet(call)
90 }
91
92 /// The call as it was before the full-window layout: a title block,
93 /// the stage in a rounded card, and the controls in a row beneath.
94 private var classic: some View {
95 VStack(spacing: 14) {
96 HStack {
97 VStack(alignment: .leading, spacing: 4) {
98 Text(call.title).font(.title2.bold()).lineLimit(1)
99 Text(call.status).font(.callout).foregroundStyle(.secondary)
100 .accessibilityAddTraits(.updatesFrequently)
101 // Said in words as well as by the button's state: a person
102 // sharing a screen should never have to wonder whether they
103 // still are.
104 if call.sharingScreen {
105 Label("You are sharing your screen", systemImage: "rectangle.on.rectangle")
106 .font(.callout.bold()).foregroundStyle(.orange)
107 if !call.screenRecovery.isEmpty {
108 Text(call.screenRecovery).font(.callout).foregroundStyle(.secondary)
109 .accessibilityIdentifier("screenRecovery")
110 }
111 } else if !call.screenNote.isEmpty {
112 Text(call.screenNote).font(.callout).foregroundStyle(.secondary)
113 }
114 }
115 Spacer()
116 if !grid, peers.count > 1 || !call.screens.isEmpty {
117 Menu {
118 pinChoices
119 } label: {
120 Label("Pin to the stage", systemImage: arrangement.pinned == nil ? "pin" : "pin.fill")
121 }
122 .help("Pin a participant or screen to the main stage, or follow whoever is speaking")
123 }
124 layoutPicker.pickerStyle(.segmented).fixedSize()
125 }
126 if call.floor {
127 Text("Direct connections only. People on some networks may be unable to connect.")
128 .font(.callout).foregroundStyle(.secondary).frame(
129 maxWidth: .infinity, alignment: .leading)
130 }
131 HStack(alignment: .top, spacing: 16) {
132 stage.frame(maxWidth: .infinity, maxHeight: .infinity)
133 if showingDevices {
134 VStack(spacing: 0) {
135 HStack {
136 Text("Devices").font(.headline)
137 Spacer()
138 Button("Hide devices", systemImage: "xmark") { showingDevices = false }
139 .labelStyle(.iconOnly).buttonStyle(.borderless).help("Hide devices")
140 }.padding(.horizontal, 16).padding(.top, 16)
141 DevicePanel(call: call)
142 Spacer(minLength: 0)
143 }.frame(width: 300)
144 .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 16))
145 }
146 if showingChat {
147 VStack(spacing: 14) {
148 HStack {
149 Text("Chat").font(.headline)
150 Spacer()
151 Button("Open chat in a window", systemImage: "arrow.up.forward.app") {
152 showingChat = false
153 chatWindow.open(call: call)
154 }.help("Open chat in a separate window")
155 Button("Hide chat", systemImage: "xmark") { showingChat = false }
156 .help("Hide chat")
157 }.labelStyle(.iconOnly).buttonStyle(.borderless)
158 ChatView(call: call, draft: chatWindow.draft)
159 }.padding(16).frame(width: 280)
160 .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 16))
161 }
162 }.frame(maxWidth: .infinity, maxHeight: .infinity)
163 ForEach(call.state?.waiting ?? []) { peer in
164 HStack {
165 Text("\(peer.name) is waiting")
166 Spacer()
167 Button("Let in") { call.admit(peer.peer) }.buttonStyle(.borderedProminent)
168 }
169 }
170 if call.state?.phase == "full" { Button("Try joining again") { call.retryJoin() } }
171 // An ended call used to stay on screen as a call with no way back
172 // into it but Leave and the lobby.
173 if call.state?.phase == "ended" {
174 HStack {
175 if let rejoin {
176 Button("Join again", action: rejoin).buttonStyle(.borderedProminent)
177 .accessibilityIdentifier("joinAgain")
178 }
179 ReportProblemButton { call.reportProblem() }.buttonStyle(.bordered)
180 }
181 }
182 controls(fullWindow: false)
183 }
184 }
185
186 var grid: Bool { arrangement.layout == .grid }
187
188 /// Everyone else, in this viewer's order.
189 var peers: [RosterPeer] {
190 let roster = call.state?.peers ?? []
191 return arrangement.others.compactMap { id in roster.first { $0.peer == id } }
192 }
193 /// What Speaker view's stage shows.
194 var focused: StageItem? { arrangement.stage(active: speakerNow.peer, screens: call.screens) }
195 /// Speaker view's filmstrip: everything not on the stage.
196 var filmstrip: [StageItem] { arrangement.filmstrip(stage: focused, screens: call.screens) }
197 /// You float over the stage, rather than sit in the filmstrip, when
198 /// you would be all there is in it (see Stage.selfFloats).
199 var selfFloats: Bool { Stage.selfFloats(filmstrip: filmstrip, me: arrangement.me) }
200 /// Grid's tiles: the screens, newest first, then everyone in order.
201 var gridItems: [StageItem] {
202 call.screens.reversed().map(StageItem.screen) + arrangement.people.map(StageItem.camera)
203 }
204
205 /// A tile for anything the call can show, as it is seen now.
206 func macTile(
207 _ item: StageItem, cornerRadius: CGFloat = 16, compact: Bool = false, labelLift: CGFloat = 0,
208 backdrop: Color = .black, onStage: Bool = false, iconOnly: Bool = false
209 ) -> MacVideoTile {
210 let pinned = onStage && arrangement.pinned == item
211 switch item {
212 case .screen(let peer):
213 return MacVideoTile(
214 track: call.remoteScreens[peer], label: screenCaption(call.name(of: peer)), screen: true,
215 cornerRadius: cornerRadius, compact: compact, labelLift: labelLift, backdrop: backdrop,
216 onStage: onStage, pinned: pinned, iconOnly: iconOnly)
217 case .camera(let peer) where peer == arrangement.me:
218 return MacVideoTile(
219 track: call.localTrack, label: "You", muted: call.muted, mirrored: true,
220 cameraOff: call.cameraOff, placeholder: call.selfPlaceholder, cornerRadius: cornerRadius,
221 compact: compact, labelLift: labelLift, backdrop: backdrop, audio: call.selfAudio,
222 onStage: onStage, pinned: pinned, iconOnly: iconOnly)
223 case .camera(let peer):
224 return MacVideoTile(
225 track: call.remoteTracks[peer], label: call.name(of: peer), muted: call.isMuted(peer),
226 cameraOff: call.isCameraOff(peer), cornerRadius: cornerRadius, compact: compact,
227 labelLift: labelLift, backdrop: backdrop, audio: call.tileAudio(peer), onStage: onStage,
228 pinned: pinned, iconOnly: iconOnly)
229 }
230 }
231
232 /// The name a menu or VoiceOver uses for `item`.
233 func itemTitle(_ item: StageItem) -> String {
234 switch item {
235 case .screen(let peer): screenCaption(call.name(of: peer))
236 case .camera(let peer): peer == arrangement.me ? "You" : call.name(of: peer)
237 }
238 }
239
240 /// A tile of Grid or the filmstrip: dragged to a new place, pinned by
241 /// a click (the filmstrip) or its menu, and both said to VoiceOver.
242 func arrangeable(_ item: StageItem, tapToPin: Bool) -> ArrangeableTile {
243 ArrangeableTile(
244 item: item, title: itemTitle(item), arrangement: arrangement, drag: $drag,
245 frames: tileFrames, tapToPin: tapToPin, touch: false, reduceMotion: reduceMotion,
246 fixtureLift: item == .camera(arrangement.fixtureLift ?? "") ? CGSize(width: 36, height: -22) : nil)
247 }
248
249 var layoutPicker: some View {
250 Picker(
251 "Video layout",
252 selection: Binding(get: { arrangement.layout }, set: { arrangement.choose($0) })
253 ) {
254 ForEach(VideoLayout.allCases) { layout in
255 Label(layout.title, systemImage: layout.symbol).tag(layout)
256 }
257 }
258 }
259
260 /// Automatic, then every screen and every person, as pin choices.
261 @ViewBuilder var pinChoices: some View {
262 Button {
263 arrangement.pin(nil)
264 } label: {
265 Label(
266 "Whoever is speaking",
267 systemImage: arrangement.pinned == nil ? "checkmark" : "waveform")
268 }
269 ForEach(call.screens.reversed(), id: \.self) { peer in
270 pinChoice(.screen(peer))
271 }
272 ForEach(arrangement.people, id: \.self) { peer in
273 pinChoice(.camera(peer))
274 }
275 }
276
277 private func pinChoice(_ item: StageItem) -> some View {
278 Button {
279 arrangement.pin(item)
280 } label: {
281 Label(itemTitle(item), systemImage: arrangement.pinned == item ? "checkmark" : "pin")
282 }
283 }
284
285 var stageShowsScreen: Bool {
286 if case .screen = focused { return true }
287 return false
288 }
289
290 @ViewBuilder private var stage: some View {
291 if grid {
292 ScrollView {
293 LazyVGrid(columns: [GridItem(.adaptive(minimum: 240))], spacing: 12) {
294 ForEach(gridItems, id: \.self) { item in
295 macTile(item)
296 .aspectRatio(16 / 9, contentMode: .fit)
297 .modifier(arrangeable(item, tapToPin: false))
298 }
299 }
300 }
301 .coordinateSpace(name: arrangeSpace)
302 .onPreferenceChange(TileFramesKey.self) { tileFrames = $0 }
303 } else {
304 GeometryReader { geometry in
305 ZStack(alignment: .bottomTrailing) {
306 if let focused {
307 macTile(focused, onStage: true)
308 .overlay(alignment: .topTrailing) {
309 if arrangement.pinned == focused {
310 PinBadge(title: itemTitle(focused)) { arrangement.pin(nil) }.padding(10)
311 }
312 }
313 } else {
314 ContentUnavailableView(
315 "Waiting for others", systemImage: "person.2",
316 description: Text("Your meeting is ready. Others will appear here when they join.")
317 )
318 .frame(maxWidth: .infinity, maxHeight: .infinity)
319 .background(CallsAppearance.background, in: RoundedRectangle(cornerRadius: 16))
320 }
321 // Everyone not on the stage, as thumbnails up its trailing
322 // edge; in a call of two, just you.
323 let strip = selfFloats ? [StageItem.camera(arrangement.me ?? "")] : filmstrip
324 let size = thumbnail(count: strip.count, in: geometry.size)
325 VStack(spacing: 10) {
326 ForEach(strip, id: \.self) { item in
327 macTile(item)
328 .frame(width: size.width, height: size.height)
329 .modifier(arrangeable(item, tapToPin: !selfFloats))
330 }
331 }
332 .shadow(color: .black.opacity(0.25), radius: 8, y: 3).padding(14)
333 }
334 .coordinateSpace(name: arrangeSpace)
335 .onPreferenceChange(TileFramesKey.self) { tileFrames = $0 }
336 }
337 }
338 }
339
340 /// A thumbnail's size, when `count` of them stack up the stage's
341 /// trailing edge: the size one always had, until there are enough of
342 /// them that it would run off the top.
343 func thumbnail(count: Int, in stage: CGSize) -> CGSize {
344 let room = (stage.height - 28 - CGFloat(count - 1) * 10) / CGFloat(max(count, 1))
345 let height = max(60, min(135, stage.height * 0.3, room))
346 return CGSize(width: min(180, stage.width * 0.3, height * 4 / 3), height: height)
347 }
348
349 /// The controls this person's bar holds, in order (CallBarFit).
350 var barControls: [CallBarFit.Control] {
351 CallBarFit.Control.allCases.filter { $0 != .link || call.standing == "member" }
352 }
353
354 /// The call's buttons, in the classic row or the full-window bar. The
355 /// bar adds the layout menu (the classic view has its picker above the
356 /// stage) and tells the controls' visibility when one has focus.
357 /// `fit` is the full-window bar's, which may tighten them and move
358 /// some into ⋯ (CallBarFit); the classic row has none and shows
359 /// everything.
360 func controls(fullWindow: Bool, fit: CallBarFit? = nil) -> some View {
361 HStack(spacing: fit?.spacing.between ?? (fullWindow ? 10 : 12)) {
362 Button(
363 call.muted ? "Unmute microphone" : "Mute microphone",
364 glyph: call.muted ? "mic.slash.fill" : "mic.fill"
365 ) { call.mute() }
366 .buttonStyle(
367 CallActionStyle(
368 color: call.muted ? Color(red: 0.65, green: 0.30, blue: 0.02) : CallsAppearance.accent,
369 emphasized: call.muted, overlay: tone(fullWindow, call.muted ? .off : .normal))
370 )
371 .help(call.muted ? "Unmute microphone" : "Mute microphone")
372 .accessibilityIdentifier("mute")
373 .focused($focusedControl, equals: .mute)
374 .modifier(CallBarEndProbe(last: false))
375 Button(
376 call.cameraOff ? "Turn camera on" : "Turn camera off",
377 glyph: call.cameraOff ? "video.slash.fill" : "video.fill"
378 ) { call.toggleCamera() }
379 .buttonStyle(
380 CallActionStyle(
381 color: call.cameraOff ? Color(red: 0.65, green: 0.30, blue: 0.02) : .blue,
382 emphasized: call.cameraOff, overlay: tone(fullWindow, call.cameraOff ? .off : .normal))
383 )
384 .help(call.cameraOff ? "Turn camera on" : "Turn camera off")
385 .accessibilityIdentifier("camera")
386 .focused($focusedControl, equals: .camera)
387 if fit?.shows(.share) ?? true {
388 Button(
389 call.sharingScreen ? "Stop sharing your screen" : "Share your screen",
390 glyph: call.sharingScreen ? "rectangle.on.rectangle.slash" : "rectangle.on.rectangle"
391 ) {
392 if call.sharingScreen {
393 call.stopSharingScreen()
394 } else {
395 Task { await call.shareScreen(MacScreenCapture()) }
396 }
397 }
398 .buttonStyle(
399 CallActionStyle(
400 color: .orange, emphasized: call.sharingScreen,
401 overlay: tone(fullWindow, call.sharingScreen ? .on : .normal)))
402 .help(call.sharingScreen ? "Stop sharing your screen" : "Share a window or your whole screen")
403 .disabled(call.state?.phase != "admitted")
404 .accessibilityIdentifier("shareScreen")
405 .focused($focusedControl, equals: .share)
406 }
407 if fit?.shows(.devices) ?? true {
408 Button("Devices", glyph: "slider.horizontal.3") {
409 withAnimation(panelAnimation) { showingDevices.toggle() }
410 }
411 .buttonStyle(
412 CallActionStyle(
413 color: .teal, emphasized: showingDevices,
414 overlay: tone(fullWindow, showingDevices ? .on : .normal)))
415 .help("Choose the camera, microphone and speaker for this call")
416 .accessibilityIdentifier("devices")
417 .focused($focusedControl, equals: .devices)
418 }
419 if fullWindow, fit?.shows(.layout) ?? true {
420 layoutMenu.focused($focusedControl, equals: .layout)
421 }
422 if fit?.shows(.link) ?? true {
423 MeetingShareButton(call: call)
424 .buttonStyle(CallActionStyle(color: .indigo, overlay: tone(fullWindow, .normal)))
425 .help("Share meeting link")
426 .focused($focusedControl, equals: .link)
427 }
428 if let fit, !fit.overflow.isEmpty {
429 moreMenu(fit.overflow)
430 }
431 Spacer(minLength: fit?.spacing.gap ?? (fullWindow ? 24 : nil))
432 Button(chatTitle, glyph: "bubble.left.and.bubble.right.fill") {
433 if chatWindow.isOpen { chatWindow.close() }
434 withAnimation(panelAnimation) { showingChat.toggle() }
435 }.buttonStyle(
436 CallActionStyle(
437 color: .purple, emphasized: showingChat || chatWindow.isOpen,
438 overlay: tone(fullWindow, showingChat || chatWindow.isOpen ? .on : .normal)))
439 .help(chatTitle)
440 .accessibilityIdentifier("openChat")
441 .focused($focusedControl, equals: .chat)
442 .overlay(alignment: .topTrailing) {
443 if unread > 0 {
444 Text("\(unread)").font(.caption.bold()).foregroundStyle(.white)
445 .padding(4).background(fullWindow ? CallOverlay.badgeFill : .purple, in: Capsule())
446 .offset(x: 5, y: -5)
447 .accessibilityHidden(true)
448 }
449 }
450 .contextMenu {
451 Button("Open chat in a window", systemImage: "arrow.up.forward.app") {
452 showingChat = false
453 chatWindow.open(call: call)
454 }
455 }
456 Button("Leave meeting", glyph: "phone.down.fill", role: .destructive, action: leave)
457 .buttonStyle(CallActionStyle(color: .red, emphasized: true, overlay: tone(fullWindow, .leave)))
458 .help("Leave meeting")
459 .accessibilityIdentifier("leave")
460 .focused($focusedControl, equals: .leave)
461 .modifier(CallBarEndProbe(last: true))
462 }.frame(maxWidth: .infinity)
463 }
464
465 /// ⋯ on a bar too narrow for everything (CallBarFit): the controls
466 /// that did not fit, as menu items, in the bar's order.
467 private func moreMenu(_ overflow: [CallBarFit.Control]) -> some View {
468 Button("More controls", glyph: "ellipsis") { showingMore.toggle() }
469 .buttonStyle(CallActionStyle(color: .gray, emphasized: showingMore, overlay: showingMore ? .on : .normal))
470 .help("More controls")
471 .accessibilityIdentifier("moreControls")
472 .popover(isPresented: $showingMore, arrowEdge: .top) {
473 VStack(alignment: .leading, spacing: 10) {
474 ForEach(overflow, id: \.self) { control in
475 moreItem(control)
476 }
477 // Spec §15.26: wherever the bar has a ⋯, "Report a problem"
478 // is in it, as it is in the ⓘ panel and the Call menu.
479 Divider()
480 Button(ProblemWords.title, systemImage: "exclamationmark.bubble") {
481 showingMore = false
482 // The popover goes first: a sheet asked for while it is
483 // still closing is not shown (DevicePanel's same wait).
484 DispatchQueue.main.asyncAfter(deadline: .now() + DevicePanel.handOver) { call.reportProblem() }
485 }
486 .accessibilityIdentifier("moreReportProblem")
487 }
488 .buttonStyle(.borderless)
489 .padding(14)
490 .frame(minWidth: 220, alignment: .leading)
491 }
492 .sheet(isPresented: $showingInvitation) {
493 MeetingInvitationView(call: call, close: { showingInvitation = false })
494 .task { await call.loadInvitation() }
495 }
496 }
497
498 @ViewBuilder private func moreItem(_ control: CallBarFit.Control) -> some View {
499 switch control {
500 case .share:
501 Button(
502 call.sharingScreen ? "Stop sharing your screen" : "Share your screen",
503 systemImage: call.sharingScreen ? "rectangle.on.rectangle.slash" : "rectangle.on.rectangle"
504 ) {
505 showingMore = false
506 if call.sharingScreen {
507 call.stopSharingScreen()
508 } else {
509 Task { await call.shareScreen(MacScreenCapture()) }
510 }
511 }
512 .disabled(call.state?.phase != "admitted")
513 case .devices:
514 Button(showingDevices ? "Hide devices" : "Devices", systemImage: "slider.horizontal.3") {
515 showingMore = false
516 withAnimation(panelAnimation) { showingDevices.toggle() }
517 }
518 case .layout:
519 layoutPicker.pickerStyle(.segmented).labelsHidden().fixedSize()
520 if !grid, peers.count > 1 || !call.screens.isEmpty {
521 Text("On the stage").font(.headline)
522 VStack(alignment: .leading, spacing: 6) { pinChoices }
523 }
524 case .link:
525 Button("Share meeting link", systemImage: "square.and.arrow.up") {
526 showingMore = false
527 showingInvitation = true
528 }
529 default:
530 EmptyView()
531 }
532 }
533
534 /// Speaker or Grid, and in Speaker, what is pinned to the stage: the
535 /// classic view's picker and pin menu, folded into one bar button and
536 /// the small popover it opens.
537 private var layoutMenu: some View {
538 Button(
539 "Video layout", glyph: arrangement.layout.symbol
540 ) { showingLayout.toggle() }
541 .buttonStyle(CallActionStyle(color: .gray, emphasized: showingLayout, overlay: showingLayout ? .on : .normal))
542 .help("Video layout: Speaker or Grid, and who is pinned to the stage")
543 .accessibilityIdentifier("videoLayout")
544 .popover(isPresented: $showingLayout, arrowEdge: .top) {
545 VStack(alignment: .leading, spacing: 12) {
546 layoutPicker.pickerStyle(.segmented).labelsHidden().fixedSize()
547 if peers.count > 1 || !call.screens.isEmpty {
548 Text("On the stage").font(.headline)
549 VStack(alignment: .leading, spacing: 6) { pinChoices }
550 .buttonStyle(.borderless)
551 }
552 }
553 .padding(14)
554 .frame(minWidth: 220, alignment: .leading)
555 }
556 }
557
558 /// The full-window bar's look for a button in `state`; the classic
559 /// row keeps its own colours.
560 private func tone(_ fullWindow: Bool, _ state: CallOverlayButtonStyle.Tone)
561 -> CallOverlayButtonStyle.Tone?
562 {
563 fullWindow ? state : nil
564 }
565
566 var panelAnimation: Animation? {
567 reduceMotion ? .linear(duration: 0.1) : .easeOut(duration: 0.2)
568 }
569
570 var unread: Int { max(0, call.receivedChatCount - readChatCount) }
571 var chatTitle: String {
572 chatWindow.isOpen
573 ? "Move chat into the sidebar"
574 : unread > 0 ? "Chat (\(unread) new)" : showingChat ? "Hide chat" : "Show chat"
575 }
576 }
577#endif
578