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
@@ -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 {
@@ -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))
}
}
@@ -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
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()
}