Files
element-x-ios/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/ActiveCallTimelineItemView.swift
Doug 3796fdc966 Fix call timeline item snapshots. (#5923)
* Fix call timeline item snapshots.

* Fix a warning and hard code the call timer during tests.
2026-07-23 14:42:44 +01:00

207 lines
8.0 KiB
Swift
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Compound
import Foundation
import SwiftUI
/// A reusable view component for rendering active call notifications.
/// This view displays information about an ongoing call including participants, duration, and a join button.
struct ActiveCallTimelineItemView: View {
@Environment(\.timelineContext) private var context
let isDM: Bool
let isVoiceCall: Bool
let activeMembers: [String]
let sender: TimelineItemSender
let isJoined: Bool
let callStartTimestamp: Date?
static let maxAvatars = 3
private var avatars: [StackedAvatarInfo] {
activeMembers
.prefix(ActiveCallTimelineItemView.maxAvatars)
.map { userID in
StackedAvatarInfo(url: context?.viewState.members[userID]?.avatarURL,
name: context?.viewState.members[userID]?.displayName,
contentID: userID)
}
}
private var extraGroupLabel: String {
if activeMembers.count == 1, activeMembers.first == sender.id {
return L10n.commonUserStartedACall(sender.displayName ?? sender.id)
} else {
return L10n.screenTimelineActiveCallJoinedCount(activeMembers.count)
}
}
var body: some View {
HStack(spacing: 8) {
CompoundIcon(isVoiceCall ? \.voiceCallSolid : \.videoCallSolid, size: .medium,
relativeTo: .compound.headingMDBold)
.padding(8)
.background(.compound.bgSubtleSecondary, in: RoundedRectangle(cornerRadius: 6))
.foregroundStyle(.compound.iconPrimary)
.accessibilityHidden(true)
if isDM {
HStack(alignment: .center, spacing: 4) {
StackedAvatarsView(overlap: 6,
lineWidth: 1,
avatars: avatars,
avatarSize: .user(on: .readReceipt),
mediaProvider: context?.mediaProvider)
.padding(-1)
let text = if activeMembers.count == 1,
activeMembers.first == sender.id {
L10n.commonUserStartedACall(sender.displayName ?? sender.id)
} else {
L10n.commonCallInProgress
}
Text(text)
.font(.compound.bodyMDSemibold)
.foregroundColor(.compound.textPrimary)
.labelStyle(.custom(spacing: 4))
}
} else {
VStack(alignment: .leading, spacing: 4) {
Text(L10n.commonGroupCallInProgress)
.font(.compound.bodyMDSemibold)
.foregroundColor(.compound.textPrimary)
.labelStyle(.custom(spacing: 4))
HStack(alignment: .center, spacing: 4) {
StackedAvatarsView(overlap: 6,
lineWidth: 1,
avatars: avatars,
avatarSize: .user(on: .readReceipt),
mediaProvider: context?.mediaProvider)
.padding(-1)
Text(extraGroupLabel)
.font(.compound.bodySM)
.foregroundColor(.compound.textSecondary)
.labelStyle(.custom(spacing: 4))
}
}
}
Spacer()
VStack(spacing: 4) {
if !isJoined {
Button { context?.send(viewAction: .joinActiveCall(isVoiceCall: isVoiceCall)) } label: {
Text(L10n.actionJoin)
.font(.compound.bodyMDSemibold)
.padding(.horizontal, 6)
.padding(.vertical, 4)
}
.buttonStyle(.compound(.primary, size: .small))
}
callTimer
.font(.compound.bodyXS)
.foregroundColor(.compound.textSecondary)
.monospacedDigit()
}
}
.padding(12)
.overlay(RoundedRectangle(cornerRadius: 8)
.stroke(.compound.borderInteractivePrimary, lineWidth: 1))
.padding(16)
}
@ViewBuilder
private var callTimer: some View {
if let callStartTimestamp {
if ProcessInfo.isRunningTests {
Text("0:00") // A slow runner can take more than a second to record the snapshot 🤦‍♂️
} else {
Text(callStartTimestamp, style: .timer)
}
}
}
}
// MARK: - Previews
struct ActiveCallTimelineItemView_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock
static let twoMembers = [
"@alice:example.org",
"@bob:example.org"
]
static let memberIDs = [
"@alice:example.org",
"@bob:example.org",
"@charlie:example.org",
"@dave:example.org",
"@joe:example.org",
"@jackie:example.org"
]
static var previews: some View {
VStack(spacing: 0) {
// Single member
ActiveCallTimelineItemView(isDM: false,
isVoiceCall: false,
activeMembers: ["@alice:example.org"],
sender: .init(id: "@alice:example.org", displayName: "Alice"),
isJoined: false,
callStartTimestamp: Date())
Divider()
// Multiple members
ActiveCallTimelineItemView(isDM: false,
isVoiceCall: false,
activeMembers: memberIDs,
sender: .init(id: "@sender:localhost"),
isJoined: false,
callStartTimestamp: nil)
Divider()
// Voice call
ActiveCallTimelineItemView(isDM: false,
isVoiceCall: true,
activeMembers: memberIDs,
sender: .init(id: "@sender:localhost"),
isJoined: false,
callStartTimestamp: nil)
Divider()
// DM
ActiveCallTimelineItemView(isDM: true,
isVoiceCall: false,
activeMembers: twoMembers,
sender: .init(id: "@sender:localhost"),
isJoined: false,
callStartTimestamp: nil)
Divider()
// DM with single member
ActiveCallTimelineItemView(isDM: true,
isVoiceCall: false,
activeMembers: ["@sender:localhost"],
sender: .init(id: "@sender:localhost", displayName: "John"),
isJoined: false,
callStartTimestamp: nil)
}
.padding()
.environmentObject(viewModel.context)
}
}