Add support for active call item rendering in timeline (#5775)

* feat(timeline): Add support for active call item rendering in timeline

* Update active call as per design (show raw count of members)

* fixup project files

* revert unexpected generated mocks change

* code review

* Record preview snapshots

* revert changes on project.yml

* post rebase fix
This commit is contained in:
Valere Fedronic
2026-07-22 16:13:46 +02:00
committed by GitHub
parent 94313173f0
commit 0f72fe3511
23 changed files with 329 additions and 51 deletions
@@ -7,6 +7,10 @@
extension AccessibilityTests { extension AccessibilityTests {
func testActiveCallTimelineItemView() async throws {
try await performAccessibilityAudit(named: "ActiveCallTimelineItemView_Previews")
}
func testAdvancedSettingsScreen() async throws { func testAdvancedSettingsScreen() async throws {
try await performAccessibilityAudit(named: "AdvancedSettingsScreen_Previews") try await performAccessibilityAudit(named: "AdvancedSettingsScreen_Previews")
} }
+4
View File
@@ -212,6 +212,7 @@
20C16A3F718802B0E4A19C83 /* URLComponentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76310030C831D4610A705603 /* URLComponentsTests.swift */; }; 20C16A3F718802B0E4A19C83 /* URLComponentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76310030C831D4610A705603 /* URLComponentsTests.swift */; };
2118E35D312951B241067BD5 /* MessageComposerTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 345172AD4377E83A44BD864F /* MessageComposerTextField.swift */; }; 2118E35D312951B241067BD5 /* MessageComposerTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 345172AD4377E83A44BD864F /* MessageComposerTextField.swift */; };
211B5F524E851178EE549417 /* CurrentValuePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 127C8472672A5BA09EF1ACF8 /* CurrentValuePublisher.swift */; }; 211B5F524E851178EE549417 /* CurrentValuePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 127C8472672A5BA09EF1ACF8 /* CurrentValuePublisher.swift */; };
21215E17C9908F1EBF621BE0 /* ActiveCallTimelineItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8201C249AE5023E44267A3A1 /* ActiveCallTimelineItemView.swift */; };
21813AF91CFC6F3E3896DB53 /* AppLockSetupBiometricsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10F130DF775CE6BC51A4E392 /* AppLockSetupBiometricsScreenModels.swift */; }; 21813AF91CFC6F3E3896DB53 /* AppLockSetupBiometricsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10F130DF775CE6BC51A4E392 /* AppLockSetupBiometricsScreenModels.swift */; };
21AFEFB8CEFE56A3811A1F5B /* VoiceMessageCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 283974987DA7EC61D2AB57D9 /* VoiceMessageCacheTests.swift */; }; 21AFEFB8CEFE56A3811A1F5B /* VoiceMessageCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 283974987DA7EC61D2AB57D9 /* VoiceMessageCacheTests.swift */; };
21F29351EDD7B2A5534EE862 /* SecureBackupKeyBackupScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD558A898847C179E4B7A237 /* SecureBackupKeyBackupScreen.swift */; }; 21F29351EDD7B2A5534EE862 /* SecureBackupKeyBackupScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD558A898847C179E4B7A237 /* SecureBackupKeyBackupScreen.swift */; };
@@ -2398,6 +2399,7 @@
81B17B1F29448D1B9049B11C /* ReportContentScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportContentScreenViewModel.swift; sourceTree = "<group>"; }; 81B17B1F29448D1B9049B11C /* ReportContentScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportContentScreenViewModel.swift; sourceTree = "<group>"; };
81B17DB1BC3B0C62AF84D230 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; 81B17DB1BC3B0C62AF84D230 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
81F0325E252B057FAEEE1B2D /* TypingIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingIndicatorView.swift; sourceTree = "<group>"; }; 81F0325E252B057FAEEE1B2D /* TypingIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingIndicatorView.swift; sourceTree = "<group>"; };
8201C249AE5023E44267A3A1 /* ActiveCallTimelineItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveCallTimelineItemView.swift; sourceTree = "<group>"; };
822B5573BDF52CF220BF9A17 /* LeaveSpaceModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaveSpaceModels.swift; sourceTree = "<group>"; }; 822B5573BDF52CF220BF9A17 /* LeaveSpaceModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaveSpaceModels.swift; sourceTree = "<group>"; };
8296D6FB451E25CEC0767BBA /* RoomNotificationSettingsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsScreenCoordinator.swift; sourceTree = "<group>"; }; 8296D6FB451E25CEC0767BBA /* RoomNotificationSettingsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsScreenCoordinator.swift; sourceTree = "<group>"; };
829D3CE4F6C4DF6CC02D33C3 /* TimelineItemProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemProviderProtocol.swift; sourceTree = "<group>"; }; 829D3CE4F6C4DF6CC02D33C3 /* TimelineItemProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemProviderProtocol.swift; sourceTree = "<group>"; };
@@ -6546,6 +6548,7 @@
CC2B160BAFD5FEAC106F89F3 /* TimelineItemViews */ = { CC2B160BAFD5FEAC106F89F3 /* TimelineItemViews */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
8201C249AE5023E44267A3A1 /* ActiveCallTimelineItemView.swift */,
B2EAFFD44F81F86012D6EC27 /* AudioRoomTimelineView.swift */, B2EAFFD44F81F86012D6EC27 /* AudioRoomTimelineView.swift */,
664ABD745A746C45CB842158 /* CallInviteRoomTimelineView.swift */, 664ABD745A746C45CB842158 /* CallInviteRoomTimelineView.swift */,
AD6E082B0507FB28F966516A /* CallNotificationRoomTimelineView.swift */, AD6E082B0507FB28F966516A /* CallNotificationRoomTimelineView.swift */,
@@ -8228,6 +8231,7 @@
70394ECD2DCC70741538620D /* AccessibilityIdentifiers.swift in Sources */, 70394ECD2DCC70741538620D /* AccessibilityIdentifiers.swift in Sources */,
28E8C44DD6E39BEB2A1599C8 /* AccessibilityTestsAppCoordinator.swift in Sources */, 28E8C44DD6E39BEB2A1599C8 /* AccessibilityTestsAppCoordinator.swift in Sources */,
2538350683F421AB620AA950 /* AccessibleLongPress.swift in Sources */, 2538350683F421AB620AA950 /* AccessibleLongPress.swift in Sources */,
21215E17C9908F1EBF621BE0 /* ActiveCallTimelineItemView.swift in Sources */,
09E1A358EA4205480AEF4698 /* AdvancedSettingsScreen.swift in Sources */, 09E1A358EA4205480AEF4698 /* AdvancedSettingsScreen.swift in Sources */,
34ADC506C929D10B138EBA51 /* AdvancedSettingsScreenCoordinator.swift in Sources */, 34ADC506C929D10B138EBA51 /* AdvancedSettingsScreenCoordinator.swift in Sources */,
2BEDEA4851E1DF901779362C /* AdvancedSettingsScreenModels.swift in Sources */, 2BEDEA4851E1DF901779362C /* AdvancedSettingsScreenModels.swift in Sources */,
@@ -10,6 +10,7 @@ import SwiftUI
enum TestablePreviewsDictionary { enum TestablePreviewsDictionary {
static let dictionary: [String: any PreviewProvider.Type] = { static let dictionary: [String: any PreviewProvider.Type] = {
var dictionary: [String: any PreviewProvider.Type] = [ var dictionary: [String: any PreviewProvider.Type] = [
"ActiveCallTimelineItemView_Previews" : ActiveCallTimelineItemView_Previews.self,
"AdvancedSettingsScreen_Previews" : AdvancedSettingsScreen_Previews.self, "AdvancedSettingsScreen_Previews" : AdvancedSettingsScreen_Previews.self,
"AnalyticsPromptScreen_Previews" : AnalyticsPromptScreen_Previews.self, "AnalyticsPromptScreen_Previews" : AnalyticsPromptScreen_Previews.self,
"AnalyticsSettingsScreen_Previews" : AnalyticsSettingsScreen_Previews.self, "AnalyticsSettingsScreen_Previews" : AnalyticsSettingsScreen_Previews.self,
@@ -75,7 +75,7 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker, case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker,
.displayDocumentPicker, .displayLocationPicker, .displayLiveLocation, .displayNewPollForm, .displayEditPollForm, .displayMediaUploadPreviewScreen, .displayDocumentPicker, .displayLocationPicker, .displayLiveLocation, .displayNewPollForm, .displayEditPollForm, .displayMediaUploadPreviewScreen,
.displaySenderDetails, .displayMessageForwarding, .displayLocation, .displayResolveSendFailure, .displaySenderDetails, .displayMessageForwarding, .displayLocation, .displayResolveSendFailure,
.displayThread, .composer, .hasScrolled, .viewInRoomTimeline, .displayRoom: .displayThread, .composer, .hasScrolled, .viewInRoomTimeline, .displayRoom, .presentCallScreen:
break break
} }
} }
@@ -100,7 +100,7 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker, case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker,
.displayDocumentPicker, .displayLocationPicker, .displayLiveLocation, .displayNewPollForm, .displayEditPollForm, .displayMediaUploadPreviewScreen, .displayDocumentPicker, .displayLocationPicker, .displayLiveLocation, .displayNewPollForm, .displayEditPollForm, .displayMediaUploadPreviewScreen,
.displaySenderDetails, .displayMessageForwarding, .displayLocation, .displayResolveSendFailure, .displaySenderDetails, .displayMessageForwarding, .displayLocation, .displayResolveSendFailure,
.displayThread, .composer, .hasScrolled, .viewInRoomTimeline, .displayRoom: .displayThread, .composer, .hasScrolled, .viewInRoomTimeline, .displayRoom, .presentCallScreen:
break break
} }
} }
@@ -97,7 +97,8 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
// These other actions will not be handled in this view // These other actions will not be handled in this view
case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker, case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker,
.displayDocumentPicker, .displayLocationPicker, .displayNewPollForm, .displayEditPollForm, .displayMediaUploadPreviewScreen, .displayDocumentPicker, .displayLocationPicker, .displayNewPollForm, .displayEditPollForm, .displayMediaUploadPreviewScreen,
.displayResolveSendFailure, .displayThread, .composer, .hasScrolled, .displayRoom, .displayMediaDetails: .displayResolveSendFailure, .displayThread, .composer, .hasScrolled, .displayRoom, .displayMediaDetails,
.presentCallScreen:
// These actions are not handled in this coordinator // These actions are not handled in this coordinator
break break
} }
@@ -162,6 +162,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
roomViewModel.timelineHasScrolled(direction: direction) roomViewModel.timelineHasScrolled(direction: direction)
case .displayRoom(let roomID, let via): case .displayRoom(let roomID, let via):
actionsSubject.send(.presentRoom(roomID: roomID, via: via)) actionsSubject.send(.presentRoom(roomID: roomID, via: via))
case .presentCallScreen(let isVoiceCall):
actionsSubject.send(.presentCallScreen(isVoiceCall: isVoiceCall))
case .viewInRoomTimeline, .displayMediaDetails: case .viewInRoomTimeline, .displayMediaDetails:
fatalError("The action: \(action) should not be sent to this coordinator") fatalError("The action: \(action) should not be sent to this coordinator")
} }
@@ -137,7 +137,7 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol {
case .displayResolveSendFailure(let failure, let sendHandle): case .displayResolveSendFailure(let failure, let sendHandle):
actionsSubject.send(.presentResolveSendFailure(failure: failure, actionsSubject.send(.presentResolveSendFailure(failure: failure,
sendHandle: sendHandle)) sendHandle: sendHandle))
case .hasScrolled, .displayRoom, .displayMediaDetails: case .hasScrolled, .displayRoom, .displayMediaDetails, .presentCallScreen:
break break
case .composer(let action): case .composer(let action):
composerViewModel.process(timelineAction: action) composerViewModel.process(timelineAction: action)
@@ -33,6 +33,7 @@ enum TimelineViewModelAction {
case viewInRoomTimeline(eventID: String, threadRootEventID: String?) case viewInRoomTimeline(eventID: String, threadRootEventID: String?)
case displayRoom(roomID: String, via: [String]) case displayRoom(roomID: String, via: [String])
case displayMediaDetails(item: EventBasedMessageTimelineItemProtocol) case displayMediaDetails(item: EventBasedMessageTimelineItemProtocol)
case presentCallScreen(isVoiceCall: Bool)
} }
enum TimelineViewPollAction { enum TimelineViewPollAction {
@@ -89,6 +90,7 @@ enum TimelineViewAction {
case hasScrolled(direction: ScrollDirection) case hasScrolled(direction: ScrollDirection)
case displayPredecessorRoom case displayPredecessorRoom
case joinActiveCall(isVoiceCall: Bool)
} }
enum TimelineComposerAction { enum TimelineComposerAction {
@@ -230,6 +230,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
} }
let serverNames = roomProxy.knownServerNames(maxCount: 50) // Limit to the same number used by ClientProxy.resolveRoomAlias(_:) let serverNames = roomProxy.knownServerNames(maxCount: 50) // Limit to the same number used by ClientProxy.resolveRoomAlias(_:)
actionsSubject.send(.displayRoom(roomID: predecessorID, via: Array(serverNames))) actionsSubject.send(.displayRoom(roomID: predecessorID, via: Array(serverNames)))
case .joinActiveCall(let isVoiceCall):
actionsSubject.send(.presentCallScreen(isVoiceCall: isVoiceCall))
} }
} }
@@ -0,0 +1,197 @@
//
// 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(action: { context?.send(viewAction: .joinActiveCall(isVoiceCall: isVoiceCall)) }) {
Text(L10n.actionJoin)
.font(.compound.bodyMDSemibold)
.padding(.horizontal, 6)
.padding(.vertical, 4)
}
.buttonStyle(.compound(.primary, size: .small))
}
if let callStartTimestamp {
Text(callStartTimestamp, style: .timer)
.font(.compound.bodyXS)
.foregroundColor(.compound.textSecondary)
.monospacedDigit()
}
}
}
.padding(12)
.overlay(RoundedRectangle(cornerRadius: 8)
.stroke(.compound.borderInteractivePrimary, lineWidth: 1))
.padding(16)
}
}
// 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)
}
}
@@ -14,12 +14,28 @@ struct CallNotificationRoomTimelineView: View {
let timelineItem: CallNotificationRoomTimelineItem let timelineItem: CallNotificationRoomTimelineItem
var body: some View { var body: some View {
switch timelineItem.callState {
case .tombstoned(let isDeclinedByMe, let isDeclined):
tombstonedCallView(isDeclinedByMe: isDeclinedByMe, isDeclined: isDeclined)
case .active(let activeMembers, let isJoined, let callStartTimestamp):
ActiveCallTimelineItemView(isDM: timelineItem.isDM,
isVoiceCall: timelineItem.isVoiceCall,
activeMembers: activeMembers,
sender: timelineItem.sender,
isJoined: isJoined,
callStartTimestamp: callStartTimestamp)
}
}
// MARK: - Tombstoned Call View
private func tombstonedCallView(isDeclinedByMe: Bool, isDeclined: Bool) -> some View {
HStack(spacing: 12) { HStack(spacing: 12) {
CompoundIcon(iconKeyPath, size: .medium, relativeTo: .compound.headingMDBold) CompoundIcon(iconKeyPath(isDeclinedByMe: isDeclinedByMe, isDeclined: isDeclined), size: .medium, relativeTo: .compound.headingMDBold)
.foregroundStyle(.compound.iconSecondary) .foregroundStyle(.compound.iconSecondary)
.accessibilityHidden(true) .accessibilityHidden(true)
Text(tileTitle) Text(tileTitle(isDeclinedByMe: isDeclinedByMe, isDeclined: isDeclined))
.font(.compound.bodyMD) .font(.compound.bodyMD)
.foregroundColor(.compound.textSecondary) .foregroundColor(.compound.textSecondary)
.labelStyle(.custom(spacing: 4)) .labelStyle(.custom(spacing: 4))
@@ -38,12 +54,12 @@ struct CallNotificationRoomTimelineView: View {
// MARK: - Private // MARK: - Private
private var tileTitle: String { private func tileTitle(isDeclinedByMe: Bool, isDeclined: Bool) -> String {
if timelineItem.isDM { if timelineItem.isDM {
// As per design only have declined variants in DM // As per design only have declined variants in DM
if timelineItem.isDeclinedByMe { if isDeclinedByMe {
L10n.commonCallYouDeclined L10n.commonCallYouDeclined
} else if timelineItem.isDeclined { } else if isDeclined {
L10n.commonCallDeclined L10n.commonCallDeclined
} else { } else {
L10n.commonCallStarted L10n.commonCallStarted
@@ -53,8 +69,8 @@ struct CallNotificationRoomTimelineView: View {
} }
} }
private var iconKeyPath: KeyPath<CompoundIcons, Image> { private func iconKeyPath(isDeclinedByMe: Bool, isDeclined: Bool) -> KeyPath<CompoundIcons, Image> {
if timelineItem.isDM, timelineItem.isDeclined || timelineItem.isDeclinedByMe { if timelineItem.isDM, isDeclined || isDeclinedByMe {
// As per design only have declined variants in DM // As per design only have declined variants in DM
timelineItem.isVoiceCall ? \.voiceCallDeclinedSolid : \.videoCallDeclinedSolid timelineItem.isVoiceCall ? \.voiceCallDeclinedSolid : \.videoCallDeclinedSolid
} else { } else {
@@ -72,68 +88,70 @@ struct CallNotificationRoomTimelineView_Previews: PreviewProvider, TestablePrevi
static var body: some View { static var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Tombstoned call previews
CallNotificationRoomTimelineView(timelineItem: .init(id: .randomEvent, CallNotificationRoomTimelineView(timelineItem: .init(id: .randomEvent,
timestamp: .mock, timestamp: .mock,
sender: .init(id: "@sender:localhost"),
isDM: false, isDM: false,
isDeclinedByMe: false, isVoiceCall: false,
isDeclined: false, callState: .tombstoned(isDeclinedByMe: false, isDeclined: false)))
isVoiceCall: false))
Divider() Divider()
CallNotificationRoomTimelineView(timelineItem: .init(id: .randomEvent, CallNotificationRoomTimelineView(timelineItem: .init(id: .randomEvent,
timestamp: .mock, timestamp: .mock,
sender: .init(id: "@sender:localhost"),
isDM: true, isDM: true,
isDeclinedByMe: false, isVoiceCall: true,
isDeclined: false, callState: .tombstoned(isDeclinedByMe: false, isDeclined: false)))
isVoiceCall: true))
Divider() Divider()
CallNotificationRoomTimelineView(timelineItem: .init(id: .randomEvent, CallNotificationRoomTimelineView(timelineItem: .init(id: .randomEvent,
timestamp: .mock, timestamp: .mock,
sender: .init(id: "@sender:localhost"),
isDM: false, isDM: false,
isDeclinedByMe: true, isVoiceCall: false,
isDeclined: true, callState: .tombstoned(isDeclinedByMe: true, isDeclined: true)))
isVoiceCall: false))
Divider() Divider()
CallNotificationRoomTimelineView(timelineItem: .init(id: .randomEvent, CallNotificationRoomTimelineView(timelineItem: .init(id: .randomEvent,
timestamp: .mock, timestamp: .mock,
sender: .init(id: "@sender:localhost"),
isDM: true, isDM: true,
isDeclinedByMe: false, isVoiceCall: true,
isDeclined: true, callState: .tombstoned(isDeclinedByMe: false, isDeclined: true)))
isVoiceCall: true))
Divider() Divider()
CallNotificationRoomTimelineView(timelineItem: .init(id: .randomEvent, CallNotificationRoomTimelineView(timelineItem: .init(id: .randomEvent,
timestamp: .mock, timestamp: .mock,
sender: .init(id: "@sender:localhost"),
isDM: true, isDM: true,
isDeclinedByMe: true, isVoiceCall: false,
isDeclined: true, callState: .tombstoned(isDeclinedByMe: false, isDeclined: true)))
isVoiceCall: true))
Divider() Divider()
CallNotificationRoomTimelineView(timelineItem: .init(id: .randomEvent, CallNotificationRoomTimelineView(timelineItem: .init(id: .randomEvent,
timestamp: .mock, timestamp: .mock,
sender: .init(id: "@sender:localhost"),
isDM: true, isDM: true,
isDeclinedByMe: false, isVoiceCall: false,
isDeclined: true, callState: .tombstoned(isDeclinedByMe: true, isDeclined: true)))
isVoiceCall: false))
Divider() Divider()
// Active call previews
CallNotificationRoomTimelineView(timelineItem: .init(id: .randomEvent, CallNotificationRoomTimelineView(timelineItem: .init(id: .randomEvent,
timestamp: .mock, timestamp: .mock,
isDM: true, sender: .init(id: "@sender:localhost", displayName: "John"),
isDeclinedByMe: true, isDM: false,
isDeclined: true, isVoiceCall: false,
isVoiceCall: false)) callState: .active(activeMembers: ["@alice:example.org"],
isJoined: false,
Divider() callStartTimestamp: Date())))
} }
.padding() .padding()
} }
@@ -11,10 +11,21 @@ import Foundation
nonisolated struct CallNotificationRoomTimelineItem: RoomTimelineItemProtocol, Equatable { nonisolated struct CallNotificationRoomTimelineItem: RoomTimelineItemProtocol, Equatable {
let id: TimelineItemIdentifier let id: TimelineItemIdentifier
let timestamp: Date let timestamp: Date
let sender: TimelineItemSender
let isDM: Bool let isDM: Bool
let isDeclinedByMe: Bool
let isDeclined: Bool
let isVoiceCall: Bool let isVoiceCall: Bool
/// Represents the state of the call notification - either tombstoned (ended) or active (ongoing)
enum CallState: Equatable {
/// A tombstoned/ended call notification
case tombstoned(isDeclinedByMe: Bool, isDeclined: Bool)
/// An active/ongoing call notification
case active(activeMembers: [String],
isJoined: Bool,
callStartTimestamp: Date?)
}
let callState: CallState
var properties = RoomTimelineItemProperties() var properties = RoomTimelineItemProperties()
} }
@@ -71,8 +71,14 @@ nonisolated struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
isOutgoing: isOutgoing) isOutgoing: isOutgoing)
case .callInvite: case .callInvite:
return buildCallInviteTimelineItem(for: eventItemProxy) return buildCallInviteTimelineItem(for: eventItemProxy)
case .rtcNotification(let callIntent, let declinedBy, _, _, _): case .rtcNotification(let callIntent, let declinedBy, let activeMembers, let callStartMillis, let isJoined):
return buildCallNotificationTimelineItem(for: eventItemProxy, isDM: isDM, callIntent: callIntent, declinedBy: declinedBy) return buildCallNotificationTimelineItem(for: eventItemProxy,
isDM: isDM,
callIntent: callIntent,
declinedBy: declinedBy,
activeMembers: activeMembers,
callStartTimestampMillis: callStartMillis,
isJoined: isJoined)
} }
} }
@@ -853,15 +859,25 @@ nonisolated struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildCallNotificationTimelineItem(for eventItemProxy: EventTimelineItemProxy, private func buildCallNotificationTimelineItem(for eventItemProxy: EventTimelineItemProxy,
isDM: Bool, isDM: Bool,
callIntent: String?, callIntent: String?,
declinedBy: [String]) -> RoomTimelineItemProtocol { declinedBy: [String],
activeMembers: [String],
callStartTimestampMillis: UInt64?,
isJoined: Bool) -> RoomTimelineItemProtocol {
let isVoiceCall = callIntent == CallIntent.audio.rawValue let isVoiceCall = callIntent == CallIntent.audio.rawValue
let callStartTimestamp = callStartTimestampMillis.map { Date(timeIntervalSince1970: TimeInterval($0 / 1000)) }
return CallNotificationRoomTimelineItem(id: eventItemProxy.id, return CallNotificationRoomTimelineItem(id: eventItemProxy.id,
timestamp: eventItemProxy.timestamp, timestamp: eventItemProxy.timestamp,
sender: eventItemProxy.sender,
isDM: isDM, isDM: isDM,
isDeclinedByMe: declinedBy.contains(userID),
isDeclined: !declinedBy.isEmpty,
isVoiceCall: isVoiceCall, isVoiceCall: isVoiceCall,
properties: .init()) callState: activeMembers.count > 0 ?
.active(activeMembers: activeMembers,
isJoined: isJoined,
callStartTimestamp: callStartTimestamp)
:
.tombstoned(isDeclinedByMe: declinedBy.contains(userID),
isDeclined: declinedBy.count > 0))
} }
// MARK: - State Events // MARK: - State Events
@@ -11,6 +11,13 @@ extension PreviewTests {
// MARK: - PreviewProvider // MARK: - PreviewProvider
@Test
func activeCallTimelineItemView() async throws {
for (index, preview) in ActiveCallTimelineItemView_Previews._allPreviews.enumerated() {
try await assertSnapshots(matching: preview, step: index)
}
}
@Test @Test
func advancedSettingsScreen() async throws { func advancedSettingsScreen() async throws {
for (index, preview) in AdvancedSettingsScreen_Previews._allPreviews.enumerated() { for (index, preview) in AdvancedSettingsScreen_Previews._allPreviews.enumerated() {
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9909a8761a5fe1af72a6aca159c2b1cb0caf086a8130437eddfe21b8a655eab7
size 138955
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:40d8037238e2d92a52ae9f6198bb625e9db2a725c6495da5174a0f3052801585
size 164464
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bcb011ac05151a5f14c14b1f636add337e76735cdea3ee43b5b4839d808e6075
size 93477
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:24a12ff5ba8829e72439b4e4605d6a5879ff2221c89eefbcc86513363e119bbc
size 132982
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4a70f54c1df8d1f12aa63cf03e270234a776954f5351934fd5e22896b9bd905a oid sha256:530f910db100c695e7e1ddb56ead0a1303c9ec732aac7f9aaec731bfade33ae6
size 114986 size 123981
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9504ac059c7eb268c5468e149248215284ca94bf7b883dec3e5b0c7bf58d7bb9 oid sha256:4cb5fc7e3ef62505c3ad1f490d2f0be0c4119d128568f44cf9ce97ae55a323a1
size 130073 size 141458
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c7cc09b9d56ca39c77feb4742b55660e04065b6de3f2766627faf3ade3a660a7 oid sha256:dc60dcd6739ad9ad98d6d98d76c1ac9f097c9ded0b39c5016bc8175cdb6d2100
size 68436 size 77972
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4847367fbc95bc1cf52a0c0c6dd137e28ceb6cfc8f2865eb53113540a2f12cd5 oid sha256:24a80a6ca6a3e880c4bdf6cfe7937296feac87e4aeff6f0377c4813b89876650
size 87310 size 101480
+1
View File
@@ -177,3 +177,4 @@ aggregateTargets:
export PATH="$PATH:/opt/homebrew/bin" export PATH="$PATH:/opt/homebrew/bin"
periphery scan --format xcode periphery scan --format xcode
basedOnDependencyAnalysis: false basedOnDependencyAnalysis: false