Add support for starting voice calls from a DM (#5305)

* feat: Start voice call from DM

* rename voiceCall:bool to isVoiceCall

* review: Fix a typo

* review: use one displayCall(bool) instead of 2 actions

* review: Add a new specific preview for DM calls

* combine startCall and startVoiceCall in single enum with isVoiceCall

* review: add isVoiceCall to presentCallScreen action

* review: Use proper a11y for voice vs  video

* add voice/video options to UserProfile Screen

* fixup: move config params to the roomInfo object

* review: Revert changes on preview as the toolbar cannot be snapshot'd

* review: Extract call controls in specific file

* oups: Add voice call option in room details screen

* Update room details screenshots

* Update user profile screenshots

* Update room member details screenshots

* fixup: remove dead code
This commit is contained in:
Valere Fedronic
2026-04-09 17:22:31 +02:00
committed by GitHub
parent 7434d531b2
commit b05ab3ac0b
76 changed files with 303 additions and 201 deletions
@@ -28,7 +28,7 @@ enum RoomDetailsScreenCoordinatorAction {
case presentInviteUsersScreen
case presentPollsHistory
case presentRolesAndPermissionsScreen
case presentCall
case presentCall(isVoiceCall: Bool)
case presentPinnedEventsTimeline
case presentMediaEventsTimeline
case presentKnockingRequestsListScreen
@@ -81,8 +81,8 @@ final class RoomDetailsScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.presentPollsHistory)
case .requestRolesAndPermissionsPresentation:
actionsSubject.send(.presentRolesAndPermissionsScreen)
case .startCall:
actionsSubject.send(.presentCall)
case .startCall(let isVoiceCall):
actionsSubject.send(.presentCall(isVoiceCall: isVoiceCall))
case .displayPinnedEventsTimeline:
actionsSubject.send(.presentPinnedEventsTimeline)
case .displayMediaEventsTimeline:
@@ -22,7 +22,7 @@ enum RoomDetailsScreenViewModelAction: Equatable {
case requestEditDetailsPresentation
case requestPollsHistoryPresentation
case requestRolesAndPermissionsPresentation
case startCall
case startCall(isVoiceCall: Bool)
case displayPinnedEventsTimeline
case displayMediaEventsTimeline
case displayKnockingRequests
@@ -94,7 +94,10 @@ struct RoomDetailsScreenViewState: BindableState {
var shortcuts: [RoomDetailsScreenViewShortcut] {
var shortcuts: [RoomDetailsScreenViewShortcut] = [.mute]
if !ProcessInfo.processInfo.isiOSAppOnMac, canJoinCall {
shortcuts.append(.call)
if isDirect {
shortcuts.append(.voiceCall)
}
shortcuts.append(.videoCall)
}
if dmRecipientInfo == nil, canInviteUsers {
shortcuts.append(.invite)
@@ -223,7 +226,7 @@ enum RoomDetailsScreenViewAction {
case toggleFavourite(isFavourite: Bool)
case processTapRolesAndPermissions
case processTapSecurityAndPrivacy
case processTapCall
case processTapCall(isVoiceCall: Bool)
case processTapPinnedEvents
case processTapMediaEvents
case processTapRequestsToJoin
@@ -233,7 +236,8 @@ enum RoomDetailsScreenViewAction {
enum RoomDetailsScreenViewShortcut {
case share(link: URL)
case mute
case call
case videoCall
case voiceCall
case invite
}
@@ -151,8 +151,8 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
Task { await toggleFavourite(isFavourite) }
case .processTapRolesAndPermissions:
actionsSubject.send(.requestRolesAndPermissionsPresentation)
case .processTapCall:
actionsSubject.send(.startCall)
case .processTapCall(let isVoiceCall):
actionsSubject.send(.startCall(isVoiceCall: isVoiceCall))
case .processTapPinnedEvents:
analyticsService.trackInteraction(name: .PinnedMessageRoomInfoButton)
actionsSubject.send(.displayPinnedEventsTimeline)
@@ -85,13 +85,22 @@ struct RoomDetailsScreen: View {
CompoundIcon(\.shareIos)
}
.buttonStyle(FormActionButtonStyle(title: L10n.actionShare))
case .call:
case .voiceCall:
Button {
context.send(viewAction: .processTapCall)
context.send(viewAction: .processTapCall(isVoiceCall: true))
} label: {
CompoundIcon(\.voiceCall)
}
.accessibilityLabel(L10n.a11yStartVoiceCall)
.buttonStyle(FormActionButtonStyle(title: L10n.actionCall))
case .videoCall:
Button {
context.send(viewAction: .processTapCall(isVoiceCall: false))
} label: {
CompoundIcon(\.videoCall)
}
.buttonStyle(FormActionButtonStyle(title: L10n.actionCall))
.accessibilityLabel(L10n.a11yStartVideoCall)
.buttonStyle(FormActionButtonStyle(title: L10n.commonVideo))
case .invite:
Button {
context.send(viewAction: .processTapInvite)
@@ -20,7 +20,7 @@ struct RoomMemberDetailsScreenCoordinatorParameters {
enum RoomMemberDetailsScreenCoordinatorAction {
case openUserProfile
case openDirectChat(roomID: String)
case startCall(roomProxy: JoinedRoomProxyProtocol)
case startCall(roomProxy: JoinedRoomProxyProtocol, isVoiceCall: Bool)
case verifyUser(userID: String)
}
@@ -51,8 +51,8 @@ final class RoomMemberDetailsScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.openUserProfile)
case .openDirectChat(let roomID):
actionsSubject.send(.openDirectChat(roomID: roomID))
case .startCall(let roomProxy):
actionsSubject.send(.startCall(roomProxy: roomProxy))
case .startCall(let roomProxy, let isVoiceCall):
actionsSubject.send(.startCall(roomProxy: roomProxy, isVoiceCall: isVoiceCall))
case .verifyUser(let userID):
actionsSubject.send(.verifyUser(userID: userID))
}
@@ -11,7 +11,7 @@ import Foundation
enum RoomMemberDetailsScreenViewModelAction {
case openUserProfile
case openDirectChat(roomID: String)
case startCall(roomProxy: JoinedRoomProxyProtocol)
case startCall(roomProxy: JoinedRoomProxyProtocol, isVoiceCall: Bool)
case verifyUser(userID: String)
}
@@ -93,7 +93,7 @@ enum RoomMemberDetailsScreenViewAction {
case displayAvatar(URL)
case openDirectChat
case createDirectChat
case startCall(roomID: String)
case startCall(roomID: String, isVoiceCall: Bool)
case verifyUser
case withdrawVerification
}
@@ -81,8 +81,8 @@ class RoomMemberDetailsScreenViewModel: RoomMemberDetailsScreenViewModelType, Ro
openDirectChat()
case .createDirectChat:
Task { await createDirectChat() }
case .startCall(let roomID):
Task { await startCall(roomID: roomID) }
case .startCall(let roomID, let isVoiceCall):
Task { await startCall(roomID: roomID, isVoiceCall: isVoiceCall) }
case .verifyUser:
actionsSubject.send(.verifyUser(userID: state.userID))
case .withdrawVerification:
@@ -225,12 +225,12 @@ class RoomMemberDetailsScreenViewModel: RoomMemberDetailsScreenViewModelType, Ro
}
}
private func startCall(roomID: String) async {
private func startCall(roomID: String, isVoiceCall: Bool) async {
guard case let .joined(roomProxy) = await userSession.clientProxy.roomForIdentifier(roomID) else {
showErrorIndicator()
return
}
actionsSubject.send(.startCall(roomProxy: roomProxy))
actionsSubject.send(.startCall(roomProxy: roomProxy, isVoiceCall: isVoiceCall))
}
// MARK: User Indicators
@@ -103,11 +103,20 @@ struct RoomMemberDetailsScreen: View {
if let roomID = context.viewState.dmRoomID {
Button {
context.send(viewAction: .startCall(roomID: roomID))
context.send(viewAction: .startCall(roomID: roomID, isVoiceCall: true))
} label: {
CompoundIcon(\.voiceCall)
}
.accessibilityLabel(L10n.a11yStartVoiceCall)
.buttonStyle(FormActionButtonStyle(title: L10n.actionCall))
Button {
context.send(viewAction: .startCall(roomID: roomID, isVoiceCall: false))
} label: {
CompoundIcon(\.videoCall)
}
.buttonStyle(FormActionButtonStyle(title: L10n.actionCall))
.accessibilityLabel(L10n.a11yStartVideoCall)
.buttonStyle(FormActionButtonStyle(title: L10n.commonVideo))
}
if let permalink = context.viewState.memberDetails?.permalink {
@@ -43,7 +43,7 @@ enum RoomScreenCoordinatorAction {
case presentEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>)
case presentRoomMemberDetails(userID: String)
case presentMessageForwarding(forwardingItem: MessageForwardingItem)
case presentCallScreen
case presentCallScreen(isVoiceCall: Bool)
case presentPinnedEventsTimeline
case presentResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, sendHandle: SendHandleProxy)
case presentKnockRequestsList
@@ -178,8 +178,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.presentPinnedEventsTimeline)
case .displayRoomDetails:
actionsSubject.send(.presentRoomDetails)
case .displayCall:
actionsSubject.send(.presentCallScreen)
case .displayCall(let isVoiceCall):
actionsSubject.send(.presentCallScreen(isVoiceCall: isVoiceCall))
case .removeComposerFocus:
composerViewModel.process(timelineAction: .removeFocus)
case .displayKnockRequests:
@@ -16,7 +16,7 @@ enum RoomScreenViewModelAction: Equatable {
case displayThread(threadRootEventID: String, focussedEventID: String)
case displayPinnedEventsTimeline
case displayRoomDetails
case displayCall
case displayCall(isVoiceCall: Bool)
case removeComposerFocus
case displayKnockRequests
case displayRoom(roomID: String, via: [String])
@@ -27,7 +27,7 @@ enum RoomScreenViewAction {
case tappedPinnedEventsBanner
case viewAllPins
case displayRoomDetails
case displayCall
case displayCall(isVoiceCall: Bool)
case footerViewAction(RoomScreenFooterViewAction)
case acceptKnock(eventID: String)
case dismissKnockRequests
@@ -62,6 +62,9 @@ struct RoomScreenViewState: BindableState {
isCallingEnabled && !isParticipatingInOngoingCall // Hide the join call button when already in the call
}
/// Whether the current room is a DM
var isDirectOneToOneRoom: Bool
var roomThreadListEnabled = false
var isKnockingEnabled = false
var isKnockableRoom = false
@@ -76,6 +76,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
let viewState = RoomScreenViewState(roomTitle: roomProxy.infoPublisher.value.displayName ?? roomProxy.id,
roomAvatar: roomProxy.infoPublisher.value.avatar,
hasOngoingCall: roomProxy.infoPublisher.value.hasRoomCall,
isDirectOneToOneRoom: roomProxy.isDirectOneToOneRoom,
hasSuccessor: roomProxy.infoPublisher.value.successor != nil,
roomHistorySharingState: roomHistorySharingState)
super.init(initialViewState: appHooks.roomScreenHook.update(viewState),
@@ -98,8 +99,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
actionsSubject.send(.displayPinnedEventsTimeline)
case .displayRoomDetails:
actionsSubject.send(.displayRoomDetails)
case .displayCall:
actionsSubject.send(.displayCall)
case .displayCall(let isVoiceCall):
actionsSubject.send(.displayCall(isVoiceCall: isVoiceCall))
actionsSubject.send(.removeComposerFocus)
analyticsService.trackInteraction(name: .MobileRoomCallButton)
case .footerViewAction(let action):
@@ -0,0 +1,76 @@
//
// 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 SwiftUI
struct RoomCallControlsToolbar: ToolbarContent {
let viewState: RoomScreenViewState
let onCallTap: (_ isVoiceCall: Bool) -> Void
var body: some ToolbarContent {
if viewState.hasOngoingCall {
ToolbarItem(placement: .primaryAction) {
JoinCallButton {
onCallTap(false)
}
.accessibilityIdentifier(A11yIdentifiers.roomScreen.joinCall)
.disabled(!viewState.canJoinCall)
}
} else {
if viewState.isDirectOneToOneRoom {
ToolbarItem(placement: .primaryAction) {
Button { onCallTap(true) } label: {
CompoundIcon(\.voiceCallSolid)
}
.accessibilityLabel(L10n.a11yStartVoiceCall)
.accessibilityIdentifier(A11yIdentifiers.roomScreen.startVoiceCall)
.disabled(!viewState.canJoinCall)
}
}
ToolbarItem(placement: .primaryAction) {
Button { onCallTap(false) } label: {
CompoundIcon(\.videoCallSolid)
}
.accessibilityLabel(L10n.a11yStartVideoCall)
.accessibilityIdentifier(A11yIdentifiers.roomScreen.startVideoCall)
.disabled(!viewState.canJoinCall)
}
}
}
}
// MARK: - Previews
struct RoomCallControlsToolbar_Previews: PreviewProvider {
static var previews: some View {
VStack(spacing: 0) {
ElementNavigationStack {
Color.clear.toolbar { RoomCallControlsToolbar(viewState: .mock(hasOngoingCall: true)) { _ in } }
}
ElementNavigationStack {
Color.clear.toolbar { RoomCallControlsToolbar(viewState: .mock(hasOngoingCall: false, isDirectOneToOneRoom: true)) { _ in } }
}
ElementNavigationStack {
Color.clear.toolbar { RoomCallControlsToolbar(viewState: .mock(hasOngoingCall: false)) { _ in } }
}
ElementNavigationStack {
Color.clear.toolbar { RoomCallControlsToolbar(viewState: .mock(hasOngoingCall: false, canJoinCall: false)) { _ in } }
}
}
.previewDisplayName("All states")
}
}
private extension RoomScreenViewState {
static func mock(hasOngoingCall: Bool, isDirectOneToOneRoom: Bool = false, canJoinCall: Bool = true) -> RoomScreenViewState {
RoomScreenViewState(roomAvatar: .room(id: "mock", name: "Mock Room", avatarURL: nil),
canJoinCall: canJoinCall,
hasOngoingCall: hasOngoingCall, isDirectOneToOneRoom: isDirectOneToOneRoom,
hasSuccessor: false)
}
}
@@ -6,6 +6,7 @@
// Please see LICENSE files in the repository root for full details.
//
import Combine
import Compound
import SwiftUI
import WysiwygComposer
@@ -172,10 +173,9 @@ struct RoomScreen: View {
}
if !ProcessInfo.processInfo.isiOSAppOnMac {
ToolbarItem(placement: .primaryAction) {
if context.viewState.shouldShowCallButton {
callButton
.disabled(!context.viewState.canJoinCall)
if context.viewState.shouldShowCallButton {
RoomCallControlsToolbar(viewState: context.viewState) { isVoiceCall in
context.send(viewAction: .displayCall(isVoiceCall: isVoiceCall))
}
}
}
@@ -194,24 +194,6 @@ struct RoomScreen: View {
}
}
}
@ViewBuilder
private var callButton: some View {
if context.viewState.hasOngoingCall {
JoinCallButton {
context.send(viewAction: .displayCall)
}
.accessibilityIdentifier(A11yIdentifiers.roomScreen.joinCall)
} else {
Button {
context.send(viewAction: .displayCall)
} label: {
CompoundIcon(\.videoCallSolid)
}
.accessibilityLabel(L10n.a11yStartCall)
.accessibilityIdentifier(A11yIdentifiers.roomScreen.joinCall)
}
}
}
// MARK: - Previews
@@ -19,7 +19,7 @@ struct UserProfileScreenCoordinatorParameters {
enum UserProfileScreenCoordinatorAction {
case openDirectChat(roomID: String)
case startCall(roomProxy: JoinedRoomProxyProtocol)
case startCall(roomProxy: JoinedRoomProxyProtocol, isVoiceCall: Bool)
case dismiss
}
@@ -48,8 +48,8 @@ final class UserProfileScreenCoordinator: CoordinatorProtocol {
switch action {
case .openDirectChat(let roomID):
actionsSubject.send(.openDirectChat(roomID: roomID))
case .startCall(let roomProxy):
actionsSubject.send(.startCall(roomProxy: roomProxy))
case .startCall(let roomProxy, let isVoiceCall):
actionsSubject.send(.startCall(roomProxy: roomProxy, isVoiceCall: isVoiceCall))
case .dismiss:
actionsSubject.send(.dismiss)
}
@@ -10,7 +10,7 @@ import Foundation
enum UserProfileScreenViewModelAction {
case openDirectChat(roomID: String)
case startCall(roomProxy: JoinedRoomProxyProtocol)
case startCall(roomProxy: JoinedRoomProxyProtocol, isVoiceCall: Bool)
case dismiss
}
@@ -43,7 +43,7 @@ enum UserProfileScreenViewAction {
case displayAvatar(URL)
case openDirectChat
case createDirectChat
case startCall(roomID: String)
case startCall(roomID: String, isVoiceCall: Bool)
case dismiss
}
@@ -62,8 +62,8 @@ class UserProfileScreenViewModel: UserProfileScreenViewModelType, UserProfileScr
openDirectChat()
case .createDirectChat:
Task { await createDirectChat() }
case .startCall(let roomID):
Task { await startCall(roomID: roomID) }
case .startCall(let roomID, let isVoiceCall):
Task { await startCall(roomID: roomID, isVoiceCall: isVoiceCall) }
case .dismiss:
actionsSubject.send(.dismiss)
}
@@ -144,12 +144,12 @@ class UserProfileScreenViewModel: UserProfileScreenViewModelType, UserProfileScr
}
}
private func startCall(roomID: String) async {
private func startCall(roomID: String, isVoiceCall: Bool) async {
guard case let .joined(roomProxy) = await userSession.clientProxy.roomForIdentifier(roomID) else {
showErrorIndicator()
return
}
actionsSubject.send(.startCall(roomProxy: roomProxy))
actionsSubject.send(.startCall(roomProxy: roomProxy, isVoiceCall: isVoiceCall))
}
// MARK: User Indicators
@@ -66,11 +66,20 @@ struct UserProfileScreen: View {
if let roomID = context.viewState.dmRoomID {
Button {
context.send(viewAction: .startCall(roomID: roomID))
context.send(viewAction: .startCall(roomID: roomID, isVoiceCall: true))
} label: {
CompoundIcon(\.voiceCall)
}
.accessibilityLabel(L10n.a11yStartVoiceCall)
.buttonStyle(FormActionButtonStyle(title: L10n.actionCall))
Button {
context.send(viewAction: .startCall(roomID: roomID, isVoiceCall: false))
} label: {
CompoundIcon(\.videoCall)
}
.buttonStyle(FormActionButtonStyle(title: L10n.actionCall))
.accessibilityLabel(L10n.a11yStartVideoCall)
.buttonStyle(FormActionButtonStyle(title: L10n.commonVideo))
}
if let permalink = context.viewState.permalink {