mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
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:
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+197
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
-31
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-2
@@ -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() {
|
||||||
|
|||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9909a8761a5fe1af72a6aca159c2b1cb0caf086a8130437eddfe21b8a655eab7
|
||||||
|
size 138955
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:40d8037238e2d92a52ae9f6198bb625e9db2a725c6495da5174a0f3052801585
|
||||||
|
size 164464
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:bcb011ac05151a5f14c14b1f636add337e76735cdea3ee43b5b4839d808e6075
|
||||||
|
size 93477
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:24a12ff5ba8829e72439b4e4605d6a5879ff2221c89eefbcc86513363e119bbc
|
||||||
|
size 132982
|
||||||
+2
-2
@@ -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
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user