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:
+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
|
||||
|
||||
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<CompoundIcons, Image> {
|
||||
if timelineItem.isDM, timelineItem.isDeclined || timelineItem.isDeclinedByMe {
|
||||
private func iconKeyPath(isDeclinedByMe: Bool, isDeclined: Bool) -> KeyPath<CompoundIcons, Image> {
|
||||
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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user