diff --git a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift index 075607e60..ebdad3809 100644 --- a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift +++ b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift @@ -7,6 +7,10 @@ extension AccessibilityTests { + func testActiveCallTimelineItemView() async throws { + try await performAccessibilityAudit(named: "ActiveCallTimelineItemView_Previews") + } + func testAdvancedSettingsScreen() async throws { try await performAccessibilityAudit(named: "AdvancedSettingsScreen_Previews") } diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 8dcde6060..a5d859da0 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -212,6 +212,7 @@ 20C16A3F718802B0E4A19C83 /* URLComponentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76310030C831D4610A705603 /* URLComponentsTests.swift */; }; 2118E35D312951B241067BD5 /* MessageComposerTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 345172AD4377E83A44BD864F /* MessageComposerTextField.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 */; }; 21AFEFB8CEFE56A3811A1F5B /* VoiceMessageCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 283974987DA7EC61D2AB57D9 /* VoiceMessageCacheTests.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 = ""; }; 81B17DB1BC3B0C62AF84D230 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 81F0325E252B057FAEEE1B2D /* TypingIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingIndicatorView.swift; sourceTree = ""; }; + 8201C249AE5023E44267A3A1 /* ActiveCallTimelineItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveCallTimelineItemView.swift; sourceTree = ""; }; 822B5573BDF52CF220BF9A17 /* LeaveSpaceModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaveSpaceModels.swift; sourceTree = ""; }; 8296D6FB451E25CEC0767BBA /* RoomNotificationSettingsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsScreenCoordinator.swift; sourceTree = ""; }; 829D3CE4F6C4DF6CC02D33C3 /* TimelineItemProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemProviderProtocol.swift; sourceTree = ""; }; @@ -6546,6 +6548,7 @@ CC2B160BAFD5FEAC106F89F3 /* TimelineItemViews */ = { isa = PBXGroup; children = ( + 8201C249AE5023E44267A3A1 /* ActiveCallTimelineItemView.swift */, B2EAFFD44F81F86012D6EC27 /* AudioRoomTimelineView.swift */, 664ABD745A746C45CB842158 /* CallInviteRoomTimelineView.swift */, AD6E082B0507FB28F966516A /* CallNotificationRoomTimelineView.swift */, @@ -8228,6 +8231,7 @@ 70394ECD2DCC70741538620D /* AccessibilityIdentifiers.swift in Sources */, 28E8C44DD6E39BEB2A1599C8 /* AccessibilityTestsAppCoordinator.swift in Sources */, 2538350683F421AB620AA950 /* AccessibleLongPress.swift in Sources */, + 21215E17C9908F1EBF621BE0 /* ActiveCallTimelineItemView.swift in Sources */, 09E1A358EA4205480AEF4698 /* AdvancedSettingsScreen.swift in Sources */, 34ADC506C929D10B138EBA51 /* AdvancedSettingsScreenCoordinator.swift in Sources */, 2BEDEA4851E1DF901779362C /* AdvancedSettingsScreenModels.swift in Sources */, diff --git a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift index da5a18f6e..3e606d17a 100644 --- a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift +++ b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift @@ -10,6 +10,7 @@ import SwiftUI enum TestablePreviewsDictionary { static let dictionary: [String: any PreviewProvider.Type] = { var dictionary: [String: any PreviewProvider.Type] = [ + "ActiveCallTimelineItemView_Previews" : ActiveCallTimelineItemView_Previews.self, "AdvancedSettingsScreen_Previews" : AdvancedSettingsScreen_Previews.self, "AnalyticsPromptScreen_Previews" : AnalyticsPromptScreen_Previews.self, "AnalyticsSettingsScreen_Previews" : AnalyticsSettingsScreen_Previews.self, diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift index 70e9f372e..c73d28a8e 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift @@ -75,7 +75,7 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker, .displayDocumentPicker, .displayLocationPicker, .displayLiveLocation, .displayNewPollForm, .displayEditPollForm, .displayMediaUploadPreviewScreen, .displaySenderDetails, .displayMessageForwarding, .displayLocation, .displayResolveSendFailure, - .displayThread, .composer, .hasScrolled, .viewInRoomTimeline, .displayRoom: + .displayThread, .composer, .hasScrolled, .viewInRoomTimeline, .displayRoom, .presentCallScreen: break } } @@ -100,7 +100,7 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker, .displayDocumentPicker, .displayLocationPicker, .displayLiveLocation, .displayNewPollForm, .displayEditPollForm, .displayMediaUploadPreviewScreen, .displaySenderDetails, .displayMessageForwarding, .displayLocation, .displayResolveSendFailure, - .displayThread, .composer, .hasScrolled, .viewInRoomTimeline, .displayRoom: + .displayThread, .composer, .hasScrolled, .viewInRoomTimeline, .displayRoom, .presentCallScreen: break } } diff --git a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenCoordinator.swift b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenCoordinator.swift index 2651f245c..e24cec72e 100644 --- a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenCoordinator.swift +++ b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenCoordinator.swift @@ -97,7 +97,8 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol { // These other actions will not be handled in this view case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker, .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 break } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index 952df0fcb..1eeaa45bd 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -162,6 +162,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol { roomViewModel.timelineHasScrolled(direction: direction) case .displayRoom(let roomID, let via): actionsSubject.send(.presentRoom(roomID: roomID, via: via)) + case .presentCallScreen(let isVoiceCall): + actionsSubject.send(.presentCallScreen(isVoiceCall: isVoiceCall)) case .viewInRoomTimeline, .displayMediaDetails: fatalError("The action: \(action) should not be sent to this coordinator") } diff --git a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift index 4c253fb0c..b3b256461 100644 --- a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift +++ b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift @@ -137,7 +137,7 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol { case .displayResolveSendFailure(let failure, let sendHandle): actionsSubject.send(.presentResolveSendFailure(failure: failure, sendHandle: sendHandle)) - case .hasScrolled, .displayRoom, .displayMediaDetails: + case .hasScrolled, .displayRoom, .displayMediaDetails, .presentCallScreen: break case .composer(let action): composerViewModel.process(timelineAction: action) diff --git a/ElementX/Sources/Screens/Timeline/TimelineModels.swift b/ElementX/Sources/Screens/Timeline/TimelineModels.swift index 6043d7f77..70f953a8a 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineModels.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineModels.swift @@ -33,6 +33,7 @@ enum TimelineViewModelAction { case viewInRoomTimeline(eventID: String, threadRootEventID: String?) case displayRoom(roomID: String, via: [String]) case displayMediaDetails(item: EventBasedMessageTimelineItemProtocol) + case presentCallScreen(isVoiceCall: Bool) } enum TimelineViewPollAction { @@ -89,6 +90,7 @@ enum TimelineViewAction { case hasScrolled(direction: ScrollDirection) case displayPredecessorRoom + case joinActiveCall(isVoiceCall: Bool) } enum TimelineComposerAction { diff --git a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift index 0e33786d8..b508e54f2 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift @@ -230,6 +230,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { } let serverNames = roomProxy.knownServerNames(maxCount: 50) // Limit to the same number used by ClientProxy.resolveRoomAlias(_:) actionsSubject.send(.displayRoom(roomID: predecessorID, via: Array(serverNames))) + case .joinActiveCall(let isVoiceCall): + actionsSubject.send(.presentCallScreen(isVoiceCall: isVoiceCall)) } } diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/ActiveCallTimelineItemView.swift b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/ActiveCallTimelineItemView.swift new file mode 100644 index 000000000..ec4ad39e3 --- /dev/null +++ b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/ActiveCallTimelineItemView.swift @@ -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) + } +} diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/CallNotificationRoomTimelineView.swift b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/CallNotificationRoomTimelineView.swift index 3455929f4..6015217b5 100644 --- a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/CallNotificationRoomTimelineView.swift +++ b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/CallNotificationRoomTimelineView.swift @@ -14,12 +14,28 @@ struct CallNotificationRoomTimelineView: View { let timelineItem: CallNotificationRoomTimelineItem 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) { - CompoundIcon(iconKeyPath, size: .medium, relativeTo: .compound.headingMDBold) + CompoundIcon(iconKeyPath(isDeclinedByMe: isDeclinedByMe, isDeclined: isDeclined), size: .medium, relativeTo: .compound.headingMDBold) .foregroundStyle(.compound.iconSecondary) .accessibilityHidden(true) - Text(tileTitle) + Text(tileTitle(isDeclinedByMe: isDeclinedByMe, isDeclined: isDeclined)) .font(.compound.bodyMD) .foregroundColor(.compound.textSecondary) .labelStyle(.custom(spacing: 4)) @@ -38,12 +54,12 @@ struct CallNotificationRoomTimelineView: View { // MARK: - Private - private var tileTitle: String { + private func tileTitle(isDeclinedByMe: Bool, isDeclined: Bool) -> String { if timelineItem.isDM { // As per design only have declined variants in DM - if timelineItem.isDeclinedByMe { + if isDeclinedByMe { L10n.commonCallYouDeclined - } else if timelineItem.isDeclined { + } else if isDeclined { L10n.commonCallDeclined } else { L10n.commonCallStarted @@ -53,8 +69,8 @@ struct CallNotificationRoomTimelineView: View { } } - private var iconKeyPath: KeyPath { - if timelineItem.isDM, timelineItem.isDeclined || timelineItem.isDeclinedByMe { + private func iconKeyPath(isDeclinedByMe: Bool, isDeclined: Bool) -> KeyPath { + if timelineItem.isDM, isDeclined || isDeclinedByMe { // As per design only have declined variants in DM timelineItem.isVoiceCall ? \.voiceCallDeclinedSolid : \.videoCallDeclinedSolid } else { @@ -72,68 +88,70 @@ struct CallNotificationRoomTimelineView_Previews: PreviewProvider, TestablePrevi static var body: some View { VStack(spacing: 0) { + // Tombstoned call previews CallNotificationRoomTimelineView(timelineItem: .init(id: .randomEvent, timestamp: .mock, + sender: .init(id: "@sender:localhost"), isDM: false, - isDeclinedByMe: false, - isDeclined: false, - isVoiceCall: false)) + isVoiceCall: false, + callState: .tombstoned(isDeclinedByMe: false, isDeclined: false))) Divider() CallNotificationRoomTimelineView(timelineItem: .init(id: .randomEvent, timestamp: .mock, + sender: .init(id: "@sender:localhost"), isDM: true, - isDeclinedByMe: false, - isDeclined: false, - isVoiceCall: true)) + isVoiceCall: true, + callState: .tombstoned(isDeclinedByMe: false, isDeclined: false))) Divider() CallNotificationRoomTimelineView(timelineItem: .init(id: .randomEvent, timestamp: .mock, + sender: .init(id: "@sender:localhost"), isDM: false, - isDeclinedByMe: true, - isDeclined: true, - isVoiceCall: false)) + isVoiceCall: false, + callState: .tombstoned(isDeclinedByMe: true, isDeclined: true))) Divider() CallNotificationRoomTimelineView(timelineItem: .init(id: .randomEvent, timestamp: .mock, + sender: .init(id: "@sender:localhost"), isDM: true, - isDeclinedByMe: false, - isDeclined: true, - isVoiceCall: true)) + isVoiceCall: true, + callState: .tombstoned(isDeclinedByMe: false, isDeclined: true))) Divider() CallNotificationRoomTimelineView(timelineItem: .init(id: .randomEvent, timestamp: .mock, + sender: .init(id: "@sender:localhost"), isDM: true, - isDeclinedByMe: true, - isDeclined: true, - isVoiceCall: true)) + isVoiceCall: false, + callState: .tombstoned(isDeclinedByMe: false, isDeclined: true))) Divider() CallNotificationRoomTimelineView(timelineItem: .init(id: .randomEvent, timestamp: .mock, + sender: .init(id: "@sender:localhost"), isDM: true, - isDeclinedByMe: false, - isDeclined: true, - isVoiceCall: false)) + isVoiceCall: false, + callState: .tombstoned(isDeclinedByMe: true, isDeclined: true))) Divider() + // Active call previews CallNotificationRoomTimelineView(timelineItem: .init(id: .randomEvent, timestamp: .mock, - isDM: true, - isDeclinedByMe: true, - isDeclined: true, - isVoiceCall: false)) - - Divider() + sender: .init(id: "@sender:localhost", displayName: "John"), + isDM: false, + isVoiceCall: false, + callState: .active(activeMembers: ["@alice:example.org"], + isJoined: false, + callStartTimestamp: Date()))) } .padding() } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/CallNotificationRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/CallNotificationRoomTimelineItem.swift index 0e738bde3..348c122fd 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/CallNotificationRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/CallNotificationRoomTimelineItem.swift @@ -11,10 +11,21 @@ import Foundation nonisolated struct CallNotificationRoomTimelineItem: RoomTimelineItemProtocol, Equatable { let id: TimelineItemIdentifier let timestamp: Date + let sender: TimelineItemSender let isDM: Bool - let isDeclinedByMe: Bool - let isDeclined: 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() } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index 2b1e2ae41..a8d72b38a 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -71,8 +71,14 @@ nonisolated struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { isOutgoing: isOutgoing) case .callInvite: return buildCallInviteTimelineItem(for: eventItemProxy) - case .rtcNotification(let callIntent, let declinedBy, _, _, _): - return buildCallNotificationTimelineItem(for: eventItemProxy, isDM: isDM, callIntent: callIntent, declinedBy: declinedBy) + case .rtcNotification(let callIntent, let declinedBy, let activeMembers, let callStartMillis, let isJoined): + 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, isDM: Bool, callIntent: String?, - declinedBy: [String]) -> RoomTimelineItemProtocol { + declinedBy: [String], + activeMembers: [String], + callStartTimestampMillis: UInt64?, + isJoined: Bool) -> RoomTimelineItemProtocol { let isVoiceCall = callIntent == CallIntent.audio.rawValue + let callStartTimestamp = callStartTimestampMillis.map { Date(timeIntervalSince1970: TimeInterval($0 / 1000)) } + return CallNotificationRoomTimelineItem(id: eventItemProxy.id, timestamp: eventItemProxy.timestamp, + sender: eventItemProxy.sender, isDM: isDM, - isDeclinedByMe: declinedBy.contains(userID), - isDeclined: !declinedBy.isEmpty, 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 diff --git a/PreviewTests/Sources/GeneratedPreviewTests.swift b/PreviewTests/Sources/GeneratedPreviewTests.swift index 398781aea..1ac7f9b67 100644 --- a/PreviewTests/Sources/GeneratedPreviewTests.swift +++ b/PreviewTests/Sources/GeneratedPreviewTests.swift @@ -11,6 +11,13 @@ extension PreviewTests { // MARK: - PreviewProvider + @Test + func activeCallTimelineItemView() async throws { + for (index, preview) in ActiveCallTimelineItemView_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + @Test func advancedSettingsScreen() async throws { for (index, preview) in AdvancedSettingsScreen_Previews._allPreviews.enumerated() { diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/activeCallTimelineItemView.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/activeCallTimelineItemView.iPad-en-GB-0.png new file mode 100644 index 000000000..acdb7a3f3 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/activeCallTimelineItemView.iPad-en-GB-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9909a8761a5fe1af72a6aca159c2b1cb0caf086a8130437eddfe21b8a655eab7 +size 138955 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/activeCallTimelineItemView.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/activeCallTimelineItemView.iPad-pseudo-0.png new file mode 100644 index 000000000..fa7c6048b --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/activeCallTimelineItemView.iPad-pseudo-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:40d8037238e2d92a52ae9f6198bb625e9db2a725c6495da5174a0f3052801585 +size 164464 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/activeCallTimelineItemView.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/activeCallTimelineItemView.iPhone-en-GB-0.png new file mode 100644 index 000000000..cf2f93c15 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/activeCallTimelineItemView.iPhone-en-GB-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bcb011ac05151a5f14c14b1f636add337e76735cdea3ee43b5b4839d808e6075 +size 93477 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/activeCallTimelineItemView.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/activeCallTimelineItemView.iPhone-pseudo-0.png new file mode 100644 index 000000000..05701a4af --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/activeCallTimelineItemView.iPhone-pseudo-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:24a12ff5ba8829e72439b4e4605d6a5879ff2221c89eefbcc86513363e119bbc +size 132982 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/callNotificationRoomTimelineView.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/callNotificationRoomTimelineView.iPad-en-GB-0.png index 32882161b..840eed1c4 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/callNotificationRoomTimelineView.iPad-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/callNotificationRoomTimelineView.iPad-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4a70f54c1df8d1f12aa63cf03e270234a776954f5351934fd5e22896b9bd905a -size 114986 +oid sha256:530f910db100c695e7e1ddb56ead0a1303c9ec732aac7f9aaec731bfade33ae6 +size 123981 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/callNotificationRoomTimelineView.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/callNotificationRoomTimelineView.iPad-pseudo-0.png index ba4668399..7ff6e01bc 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/callNotificationRoomTimelineView.iPad-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/callNotificationRoomTimelineView.iPad-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9504ac059c7eb268c5468e149248215284ca94bf7b883dec3e5b0c7bf58d7bb9 -size 130073 +oid sha256:4cb5fc7e3ef62505c3ad1f490d2f0be0c4119d128568f44cf9ce97ae55a323a1 +size 141458 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/callNotificationRoomTimelineView.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/callNotificationRoomTimelineView.iPhone-en-GB-0.png index 22d97a383..7b71576c5 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/callNotificationRoomTimelineView.iPhone-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/callNotificationRoomTimelineView.iPhone-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c7cc09b9d56ca39c77feb4742b55660e04065b6de3f2766627faf3ade3a660a7 -size 68436 +oid sha256:dc60dcd6739ad9ad98d6d98d76c1ac9f097c9ded0b39c5016bc8175cdb6d2100 +size 77972 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/callNotificationRoomTimelineView.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/callNotificationRoomTimelineView.iPhone-pseudo-0.png index 441a7343a..511377d32 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/callNotificationRoomTimelineView.iPhone-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/callNotificationRoomTimelineView.iPhone-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4847367fbc95bc1cf52a0c0c6dd137e28ceb6cfc8f2865eb53113540a2f12cd5 -size 87310 +oid sha256:24a80a6ca6a3e880c4bdf6cfe7937296feac87e4aeff6f0377c4813b89876650 +size 101480 diff --git a/project.yml b/project.yml index d44d68db2..373c3cc15 100644 --- a/project.yml +++ b/project.yml @@ -177,3 +177,4 @@ aggregateTargets: export PATH="$PATH:/opt/homebrew/bin" periphery scan --format xcode basedOnDependencyAnalysis: false +