diff --git a/WireDomain/Sources/WireDomain/Components/ClientSessionComponent.swift b/WireDomain/Sources/WireDomain/Components/ClientSessionComponent.swift index 5b757d5ca41..017e2e24fc2 100644 --- a/WireDomain/Sources/WireDomain/Components/ClientSessionComponent.swift +++ b/WireDomain/Sources/WireDomain/Components/ClientSessionComponent.swift @@ -798,10 +798,13 @@ public final class ClientSessionComponent { // MARK: - Other + public let callReactionSubject = PassthroughSubject() + public private(set) lazy var conversationProtobufMessageProcessor = ConversationProtobufMessageProcessor( messageLocalStore: messageLocalStore, conversationLocalStore: conversationLocalStore, - userLocalStore: userLocalStore + userLocalStore: userLocalStore, + onCallReaction: { [callReactionSubject] event in callReactionSubject.send(event) } ) public private(set) lazy var oneOnOneResolver = OneOnOneResolver( diff --git a/WireDomain/Sources/WireDomain/Event Processing/ConversationEventProcessor/ConversationProtobufMessageProcessor.swift b/WireDomain/Sources/WireDomain/Event Processing/ConversationEventProcessor/ConversationProtobufMessageProcessor.swift index 1abeaef61f2..933380218d0 100644 --- a/WireDomain/Sources/WireDomain/Event Processing/ConversationEventProcessor/ConversationProtobufMessageProcessor.swift +++ b/WireDomain/Sources/WireDomain/Event Processing/ConversationEventProcessor/ConversationProtobufMessageProcessor.swift @@ -26,15 +26,18 @@ public struct ConversationProtobufMessageProcessor: ConversationProtobufMessageP let messageLocalStore: any MessageLocalStoreProtocol let conversationLocalStore: any ConversationLocalStoreProtocol let userLocalStore: any UserLocalStoreProtocol + let onCallReaction: ((CallReactionEvent) -> Void)? public init( messageLocalStore: any MessageLocalStoreProtocol, conversationLocalStore: any ConversationLocalStoreProtocol, - userLocalStore: any UserLocalStoreProtocol + userLocalStore: any UserLocalStoreProtocol, + onCallReaction: ((CallReactionEvent) -> Void)? = nil ) { self.messageLocalStore = messageLocalStore self.conversationLocalStore = conversationLocalStore self.userLocalStore = userLocalStore + self.onCallReaction = onCallReaction } public func processProtobufMessage( @@ -182,10 +185,15 @@ public struct ConversationProtobufMessageProcessor: ConversationProtobufMessageP // case not handled here, see `onProcessedCallEvent` break - case .inCallEmoji: + case let .inCallEmoji(inCallEmoji): - // Not supported yet, just discard. TODO: [WPB-11770] implement here - break + // TODO: [WPB-11770] add unit tests for inCallEmoji processing + let sender = await userLocalStore.fetchOrCreateUser(id: senderID.id, domain: senderID.domain) + let senderName = await userLocalStore.name(for: sender) ?? "" + for (emoji, _) in inCallEmoji.emojis { + WireLogger.calling.debug("Received in-call emoji reaction: \(emoji)", attributes: logAttributes) + onCallReaction?(CallReactionEvent(emoji: emoji, senderID: senderID.id, senderName: senderName, conversationID: conversationID.id)) + } case .image, .asset: diff --git a/WireDomain/Sources/WireDomain/Event Processing/ConversationEventProcessor/Models/CallReactionEvent.swift b/WireDomain/Sources/WireDomain/Event Processing/ConversationEventProcessor/Models/CallReactionEvent.swift new file mode 100644 index 00000000000..65dfe37beeb --- /dev/null +++ b/WireDomain/Sources/WireDomain/Event Processing/ConversationEventProcessor/Models/CallReactionEvent.swift @@ -0,0 +1,33 @@ +// +// Wire +// Copyright (C) 2026 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import Foundation + +public struct CallReactionEvent: Sendable { + public let emoji: String + public let senderID: UUID + public let senderName: String + public let conversationID: UUID + + public init(emoji: String, senderID: UUID, senderName: String, conversationID: UUID) { + self.emoji = emoji + self.senderID = senderID + self.senderName = senderName + self.conversationID = conversationID + } +} diff --git a/WireUI/Sources/WireLocators/Locators.swift b/WireUI/Sources/WireLocators/Locators.swift index f37dd2aefdc..7f5350f8baf 100644 --- a/WireUI/Sources/WireLocators/Locators.swift +++ b/WireUI/Sources/WireLocators/Locators.swift @@ -418,6 +418,7 @@ public enum Locators { case endOngoingCallButton = "End call" case timeLabel + case callReactionButton public static func participantIdentifier(_ name: String) -> String { "audioView.\(name).minimized.inactive" diff --git a/wire-ios-data-model/Source/Utilis/Protos/GenericMessage+Helper.swift b/wire-ios-data-model/Source/Utilis/Protos/GenericMessage+Helper.swift index c9f068028a4..5938d4cfecd 100644 --- a/wire-ios-data-model/Source/Utilis/Protos/GenericMessage+Helper.swift +++ b/wire-ios-data-model/Source/Utilis/Protos/GenericMessage+Helper.swift @@ -86,6 +86,15 @@ public extension GenericMessage { $0.clientAction = action } } + + init(emoji: String, count: Int32 = 1) { + self = GenericMessage.with { + $0.inCallEmoji = .with { + $0.emojis = [emoji: count] + } + } + } + } public extension GenericMessage { diff --git a/wire-ios-sync-engine/Source/Calling/CallKitManager.swift b/wire-ios-sync-engine/Source/Calling/CallKitManager.swift index 5f00d570dbd..89981d9db85 100644 --- a/wire-ios-sync-engine/Source/Calling/CallKitManager.swift +++ b/wire-ios-sync-engine/Source/Calling/CallKitManager.swift @@ -476,7 +476,9 @@ public class CallKitManager: NSObject, CallKitManagerInterface { if let error { self?.logger.error("fail: report incoming call: \(error)", attributes: .safePublic) self?.callRegister.unregisterCall(call) - conversation.voiceChannel?.leave() + conversation.managedObjectContext?.perform { + conversation.voiceChannel?.leave() + } } else { self?.logger.info("success: report incoming call", attributes: .safePublic) self?.mediaManager?.setupAudioDevice() diff --git a/wire-ios-sync-engine/Source/Synchronization/StrategyDirectory.swift b/wire-ios-sync-engine/Source/Synchronization/StrategyDirectory.swift index 11c92b37098..9d2e5dc659a 100644 --- a/wire-ios-sync-engine/Source/Synchronization/StrategyDirectory.swift +++ b/wire-ios-sync-engine/Source/Synchronization/StrategyDirectory.swift @@ -38,6 +38,7 @@ public class StrategyDirectory: NSObject, StrategyDirectoryProtocol { public private(set) var requestStrategies: [RequestStrategy] public private(set) var contextChangeTrackers: [ZMContextChangeTracker] public private(set) var clientContextChangeTrackers: [ZMContextChangeTracker] = [] + public private(set) var messageSender: MessageSenderInterface? init( contextProvider: ContextProvider, @@ -332,6 +333,7 @@ public class StrategyDirectory: NSObject, StrategyDirectoryProtocol { featureRepository: LegacyFeatureRepository(context: syncContext), apiVersion: metadata.apiVersion ) + self.messageSender = messageSender let strategies: [Any] = [ AssetClientMessageRequestStrategy( diff --git a/wire-ios-sync-engine/Source/Use cases/SendCallReactionUseCase.swift b/wire-ios-sync-engine/Source/Use cases/SendCallReactionUseCase.swift new file mode 100644 index 00000000000..d56a153c215 --- /dev/null +++ b/wire-ios-sync-engine/Source/Use cases/SendCallReactionUseCase.swift @@ -0,0 +1,52 @@ +// +// Wire +// Copyright (C) 2026 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import CoreData +import GenericMessageProtocol +import WireDataModel +import WireRequestStrategy + +public protocol SendCallReactionUseCaseProtocol { + func invoke(emoji: String, in conversation: ZMConversation) async throws +} + +public struct SendCallReactionUseCase: SendCallReactionUseCaseProtocol, Sendable { + + let messageSender: MessageSenderInterface + let context: NSManagedObjectContext + + public init(messageSender: MessageSenderInterface, context: NSManagedObjectContext) { + self.messageSender = messageSender + self.context = context + } + + public func invoke(emoji: String, in conversation: ZMConversation) async throws { + let managedObjectID = conversation.objectID + let syncConversation = try await context.perform { + try context.existingObject(with: managedObjectID) as? ZMConversation + } + let content = InCallEmoji.with { $0.emojis = [emoji: 1] } + let entity = GenericMessageEntity( + message: GenericMessage(content: content), + context: context, + conversation: syncConversation, + completionHandler: nil + ) + try await messageSender.sendMessage(message: entity) + } +} diff --git a/wire-ios-sync-engine/Source/UserSession/UserSession.swift b/wire-ios-sync-engine/Source/UserSession/UserSession.swift index f98e52c4131..3555f019162 100644 --- a/wire-ios-sync-engine/Source/UserSession/UserSession.swift +++ b/wire-ios-sync-engine/Source/UserSession/UserSession.swift @@ -298,6 +298,8 @@ public protocol UserSession: AnyObject { func makeSearchUsersUseCase() -> SearchUsersUseCaseProtocol? + func makeSendCallReactionUseCase() -> SendCallReactionUseCaseProtocol? + func fetchSelfConversationMLSGroupID() async -> MLSGroupID? func resolveOneOnOneConversation(with userID: WireDataModel diff --git a/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession+UserSession.swift b/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession+UserSession.swift index f25aa12687f..73f5e588959 100644 --- a/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession+UserSession.swift +++ b/wire-ios-sync-engine/Source/UserSession/ZMUserSession/ZMUserSession+UserSession.swift @@ -401,6 +401,13 @@ extension ZMUserSession: UserSession { ) } + public func makeSendCallReactionUseCase() -> SendCallReactionUseCaseProtocol? { + guard let messageSender = (strategyDirectory as? StrategyDirectory)?.messageSender else { + return nil + } + return SendCallReactionUseCase(messageSender: messageSender, context: syncContext) + } + public var resolvedBackendMetadata: BackendMetadataProvider { let metadata = userSessionComponent.backendMetadata return BackendMetadataProvider( diff --git a/wire-ios-utilities/Source/DeveloperFlag.swift b/wire-ios-utilities/Source/DeveloperFlag.swift index fac14a6948a..d19b0e02393 100644 --- a/wire-ios-utilities/Source/DeveloperFlag.swift +++ b/wire-ios-utilities/Source/DeveloperFlag.swift @@ -46,6 +46,7 @@ public enum DeveloperFlag: String, CaseIterable { // TODO: [WPB-25941] Remove drive permissions flag when feature is complete case enableDrivePermissions case unSafeLogsForPublic + case inCallReactions public var description: String { switch self { @@ -117,6 +118,9 @@ public enum DeveloperFlag: String, CaseIterable { case .unSafeLogsForPublic: "Turn on to write all logs (including debug and non-public) to disk in release builds" + + case .inCallReactions: + "Turn on to enable in-call emoji reactions" } } diff --git a/wire-ios/Wire-iOS Tests/Calling/VoiceChannelStreamArrangmentTests.swift b/wire-ios/Wire-iOS Tests/Calling/VoiceChannelStreamArrangmentTests.swift index 5fec79c5179..a17b7cc499a 100644 --- a/wire-ios/Wire-iOS Tests/Calling/VoiceChannelStreamArrangmentTests.swift +++ b/wire-ios/Wire-iOS Tests/Calling/VoiceChannelStreamArrangmentTests.swift @@ -207,6 +207,7 @@ class VoiceChannelStreamArrangementTests: XCTestCase { // MARK: - Video streams sorting + // TODO: Replace with a test for sortByPriority (screenshare → active speaker → rest) once sortByVideo is removed func testThatItReturnsSortedParticipantsInGrid_ByVideoState() { // GIVEN let participant1 = participantStub(for: mockUser1, videoEnabled: true) diff --git a/wire-ios/Wire-iOS/Generated/Strings+Generated.swift b/wire-ios/Wire-iOS/Generated/Strings+Generated.swift index 65524ec88bf..a22f7dd3f45 100644 --- a/wire-ios/Wire-iOS/Generated/Strings+Generated.swift +++ b/wire-ios/Wire-iOS/Generated/Strings+Generated.swift @@ -6561,6 +6561,10 @@ internal enum L10n { internal static let title = L10n.tr("Localizable", "voice.call_error.unsupported_version.title", fallback: "Please update Wire") } } + internal enum CallReactionButton { + /// Emoji + internal static let title = L10n.tr("Localizable", "voice.call_reaction_button.title", fallback: "Emoji") + } internal enum Calling { /// Calling... internal static let title = L10n.tr("Localizable", "voice.calling.title", fallback: "Calling...") diff --git a/wire-ios/Wire-iOS/Resources/Localization/Base.lproj/Localizable.strings b/wire-ios/Wire-iOS/Resources/Localization/Base.lproj/Localizable.strings index 3c22c87aaad..1cb94bddfd1 100644 --- a/wire-ios/Wire-iOS/Resources/Localization/Base.lproj/Localizable.strings +++ b/wire-ios/Wire-iOS/Resources/Localization/Base.lproj/Localizable.strings @@ -815,6 +815,7 @@ "voice.video_button.title" = "Camera"; "voice.flip_video_button.title" = "Switch camera"; "voice.speaker_button.title" = "Speaker"; +"voice.call_reaction_button.title" = "Emoji"; "voice.cancel_button.title" = "Cancel"; "voice.call_button.title" = "Call"; "voice.end_call_button.title" = "End Call"; diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/ActiveCall/ActiveCallActionsView.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/ActiveCall/ActiveCallActionsView.swift new file mode 100644 index 00000000000..c82cc1ec621 --- /dev/null +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/ActiveCall/ActiveCallActionsView.swift @@ -0,0 +1,196 @@ +// +// Wire +// Copyright (C) 2026 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import SwiftUI +import WireDesign +import WireLocators +import WireUtilities + +// MARK: - Configuration + +struct CallActionsConfiguration { + var isMuted: Bool + var isSendingVideo: Bool + var canAcceptVideoCalls: Bool + var canToggleMediaType: Bool + var isSpeakerEnabled: Bool + var canSpeakerBeToggled: Bool +} + +extension CallActionsConfiguration { + init(_ configuration: CallInfoConfiguration) { + isMuted = configuration.isMuted + isSendingVideo = configuration.mediaState.isSendingVideo + canAcceptVideoCalls = configuration.permissions.canAcceptVideoCalls + canToggleMediaType = configuration.canToggleMediaType + isSpeakerEnabled = configuration.mediaState.isSpeakerEnabled + canSpeakerBeToggled = configuration.mediaState.canSpeakerBeToggled + } +} + +// MARK: - View + +/// Shared active-call button container used in both portrait (`.horizontal`) and +/// landscape (`.vertical`) orientations. See `CallActionsView` for Incoming-call controls. +struct ActiveCallActionsView: View { + + let axis: Axis + var isReactionsTrayOpen: Bool = false + var configuration: CallActionsConfiguration + let performAction: (CallAction) -> Void + + var body: some View { + if axis == .vertical { + VStack(spacing: 0) { + muteButton.frame(maxWidth: .infinity, maxHeight: .infinity) + videoButton.frame(maxWidth: .infinity, maxHeight: .infinity) + speakerButton.frame(maxWidth: .infinity, maxHeight: .infinity) + if DeveloperFlag.inCallReactions.isOn { + callReactionButton.frame(maxWidth: .infinity, maxHeight: .infinity) + } + hangupButton.frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea(.all) + + } else { + VStack(spacing: 0) { + + HStack(spacing: 0) { + muteButton.frame(maxWidth: .infinity) + videoButton.frame(maxWidth: .infinity) + speakerButton.frame(maxWidth: .infinity) + if DeveloperFlag.inCallReactions.isOn { + callReactionButton.frame(maxWidth: .infinity) + } + hangupButton.frame(maxWidth: .infinity) + } + .padding(.horizontal, 14) + .frame(height: 64) + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + } + + // MARK: - Buttons + + private var muteButton: some View { + CallActionButton( + systemImage: configuration.isMuted ? "mic.slash.fill" : "mic.fill", + isSelected: !configuration.isMuted, + isDestructive: false + ) { performAction(.toggleMuteState) } + } + + private var videoButton: some View { + CallActionButton( + systemImage: configuration.isSendingVideo ? "video.fill" : "video.slash.fill", + isSelected: configuration.isSendingVideo && configuration.canAcceptVideoCalls, + isDestructive: false, + isEnabled: configuration.canToggleMediaType + ) { performAction(.toggleVideoState) } + } + + private var speakerButton: some View { + CallActionButton( + systemImage: configuration.isSpeakerEnabled ? "speaker.wave.2.fill" : "speaker.slash.fill", + isSelected: configuration.isSpeakerEnabled, + isDestructive: false, + isEnabled: configuration.canSpeakerBeToggled + ) { performAction(.toggleSpeakerState) } + } + + private var callReactionButton: some View { + CallActionButton( + systemImage: "face.smiling", + isSelected: isReactionsTrayOpen, + isDestructive: false + ) { performAction(.toggleReactionsTray) } + .accessibilityIdentifier(Locators.OngoingCallPage.callReactionButton) + .accessibilityLabel(L10n.Localizable.Voice.CallReactionButton.title) + } + + private var hangupButton: some View { + CallActionButton( + systemImage: "phone.down.fill", + isSelected: false, + isDestructive: true + ) { performAction(.terminateCall) } + } +} + +// MARK: - Previews + +private let previewConfig = CallActionsConfiguration( + isMuted: false, + isSendingVideo: true, + canAcceptVideoCalls: true, + canToggleMediaType: true, + isSpeakerEnabled: false, + canSpeakerBeToggled: true +) + +#Preview("Portrait — horizontal row") { + ActiveCallActionsView( + axis: .horizontal, + isReactionsTrayOpen: false, + configuration: previewConfig, + performAction: { _ in } + ) + .frame(height: 72) + .background(Color(.systemBackground)) + .previewInterfaceOrientation(.portrait) +} + +#Preview("Landscape — vertical column", traits: .landscapeRight) { + HStack { + Spacer() + ActiveCallActionsView( + axis: .vertical, + isReactionsTrayOpen: true, + configuration: previewConfig, + performAction: { _ in } + ) + .frame(width: 72) + .frame(maxHeight: .infinity) + .background(Color(.systemGray6)) + .padding(16) + .ignoresSafeArea() + } + + .ignoresSafeArea(.all) +} + +#Preview("Muted / video off / speaker on") { + ActiveCallActionsView( + axis: .horizontal, + configuration: CallActionsConfiguration( + isMuted: true, + isSendingVideo: false, + canAcceptVideoCalls: true, + canToggleMediaType: true, + isSpeakerEnabled: true, + canSpeakerBeToggled: true + ), + performAction: { _ in } + ) + .frame(height: 72) + .background(Color(.systemBackground)) +} diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/ActiveCall/CallActionButton.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/ActiveCall/CallActionButton.swift new file mode 100644 index 00000000000..3e1fbaf2594 --- /dev/null +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/ActiveCall/CallActionButton.swift @@ -0,0 +1,94 @@ +// +// Wire +// Copyright (C) 2026 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import SwiftUI +import WireDesign + +/// A button used for in-call actions (mute, video, speaker, hang up). +struct CallActionButton: View { + + let systemImage: String + let isSelected: Bool + let isDestructive: Bool + var isEnabled: Bool = true + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: systemImage) + .font(.system(size: 20, weight: .medium)) + .foregroundColor(iconColor) + .frame(width: 56, height: 40) + .background( + RoundedRectangle(cornerSize: CGSize(width: 100, height: 100)) + .fill(backgroundColor) + .overlay( + RoundedRectangle(cornerSize: CGSize(width: 100, height: 100)) + .stroke(borderColor, lineWidth: 1) + ) + ) + } + .disabled(!isEnabled) + } + + private var backgroundColor: Color { + if isDestructive { + return Color(SemanticColors.Button.backgroundLikeHighlighted) + } + return isSelected + ? Color(SemanticColors.Button.backgroundCallingSelected) + : Color(SemanticColors.Button.backgroundCallingNormal) + } + + private var iconColor: Color { + if isDestructive { + return Color(SemanticColors.View.backgroundDefaultWhite) + } + return isSelected + ? Color(SemanticColors.Button.iconCallingSelected) + : Color(SemanticColors.Button.iconCallingNormal) + } + + private var borderColor: Color { + if isDestructive { return .clear } + return isSelected + ? Color(SemanticColors.Button.borderCallingSelected) + : Color(SemanticColors.Button.borderCallingNormal) + } +} + +// MARK: - Preview + +#Preview("States") { + VStack(spacing: 16) { + HStack(spacing: 8) { + CallActionButton(systemImage: "mic.slash.fill", isSelected: false, isDestructive: false) {} + CallActionButton(systemImage: "mic.slash.fill", isSelected: true, isDestructive: false) {} + CallActionButton(systemImage: "mic.slash.fill", isSelected: false, isDestructive: false, isEnabled: false) {} + } + HStack(spacing: 8) { + CallActionButton(systemImage: "video.fill", isSelected: false, isDestructive: false) {} + CallActionButton(systemImage: "video.fill", isSelected: true, isDestructive: false) {} + } + HStack(spacing: 8) { + CallActionButton(systemImage: "phone.down.fill", isSelected: false, isDestructive: true) {} + } + } + .padding() + .background(Color(.systemGray6)) +} diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/ActiveCall/CallReactionWall.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/ActiveCall/CallReactionWall.swift new file mode 100644 index 00000000000..04d1a6161ce --- /dev/null +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/ActiveCall/CallReactionWall.swift @@ -0,0 +1,50 @@ +// +// Wire +// Copyright (C) 2026 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import SwiftUI + +/// Transparent overlay drawn on top of the call grid. +/// Received emoji reactions float upward with the sender's name. +struct CallReactionWall: View { + + var viewModel: CallReactionWallViewModel + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .bottomLeading) { + ForEach(viewModel.items) { item in + VStack(spacing: 2) { + Text(item.emoji) + .font(.system(size: 36)) + Text(item.senderName) + .font(.caption2) + .fontWeight(.semibold) + .foregroundStyle(.white) + .shadow(radius: 2) + } + .offset(x: item.xOffset, y: item.yOffset) + .opacity(item.opacity) + } + } + .frame(width: geo.size.width, height: geo.size.height, alignment: .bottomLeading) + .onAppear { viewModel.containerHeight = geo.size.height } + .onChange(of: geo.size.height) { _, height in viewModel.containerHeight = height } + } + .allowsHitTesting(false) + } +} diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/ActiveCall/CallReactionWallViewModel.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/ActiveCall/CallReactionWallViewModel.swift new file mode 100644 index 00000000000..4b4fec15a24 --- /dev/null +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/ActiveCall/CallReactionWallViewModel.swift @@ -0,0 +1,139 @@ +// +// Wire +// Copyright (C) 2026 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import Combine +import SwiftUI +import WireDataModel +import WireDomain +import WireSyncEngine + +struct ReactionItem: Identifiable { + let id: UUID + let senderID: UUID + let emoji: String + let senderName: String + let xOffset: CGFloat + var yOffset: CGFloat + var opacity: Double +} + +@MainActor +@Observable +final class CallReactionWallViewModel { + + private(set) var items: [ReactionItem] = [] + var containerHeight: CGFloat = 800 + + /// Last emoji received per sender, held for 1 second after the floating animation ends. + /// Keyed by sender user ID so video tiles can badge the sender. + private(set) var lastReactions: [UUID: String] = [:] + + private var cancellable: AnyCancellable? + private var expiryWorkItems: [UUID: DispatchWorkItem] = [:] + private let reactionsSubject = PassthroughSubject<[UUID: String], Never>() + + private let conversationID: UUID + private let sendUseCase: SendCallReactionUseCaseProtocol? + private weak var conversation: ZMConversation? + private let selfSenderID: UUID + private let selfSenderName: String + + var reactionsPublisher: AnyPublisher<[UUID: String], Never> { + reactionsSubject.eraseToAnyPublisher() + } + + init( + publisher: AnyPublisher, + conversationID: UUID, + sendUseCase: SendCallReactionUseCaseProtocol? = nil, + conversation: ZMConversation? = nil, + selfSenderID: UUID = UUID(), + selfSenderName: String = "" + ) { + self.conversationID = conversationID + self.sendUseCase = sendUseCase + self.conversation = conversation + self.selfSenderID = selfSenderID + self.selfSenderName = selfSenderName + cancellable = publisher + .filter { $0.conversationID == conversationID } + .receive(on: RunLoop.main) + .sink { [weak self] event in + self?.add(event) + } + } + + func sendReaction(emoji: String) { + guard let useCase = sendUseCase, let conversation else { return } + addLocal(CallReactionEvent( + emoji: emoji, + senderID: selfSenderID, + senderName: selfSenderName, + conversationID: conversationID + )) + Task { try? await useCase.invoke(emoji: emoji, in: conversation) } + } + + private func addLocal(_ event: CallReactionEvent) { + add(event) + } + + private func add(_ event: CallReactionEvent) { + let id = UUID() + items.append(ReactionItem( + id: id, + senderID: event.senderID, + emoji: event.emoji, + senderName: event.senderName, + xOffset: CGFloat.random(in: 16...260), + yOffset: 0, + opacity: 1 + )) + + withAnimation(.easeOut(duration: 2.5)) { + guard let index = items.firstIndex(where: { $0.id == id }) else { return } + items[index].yOffset = -containerHeight + } + + withAnimation(.easeOut(duration: 1.0).delay(1.5)) { + guard let index = items.firstIndex(where: { $0.id == id }) else { return } + items[index].opacity = 0 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.6) { [weak self] in + guard let self else { return } + guard let item = items.first(where: { $0.id == id }) else { return } + items.removeAll { $0.id == id } + recordLastReaction(emoji: item.emoji, senderID: item.senderID) + } + } + + private func recordLastReaction(emoji: String, senderID: UUID) { + lastReactions[senderID] = emoji + reactionsSubject.send(lastReactions) + + expiryWorkItems[senderID]?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.lastReactions.removeValue(forKey: senderID) + self?.expiryWorkItems.removeValue(forKey: senderID) + self.map { $0.reactionsSubject.send($0.lastReactions) } + } + expiryWorkItems[senderID] = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: workItem) + } +} diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/ActiveCall/CallReactionsTray.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/ActiveCall/CallReactionsTray.swift new file mode 100644 index 00000000000..ebc73b84f9d --- /dev/null +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/ActiveCall/CallReactionsTray.swift @@ -0,0 +1,127 @@ +// +// Wire +// Copyright (C) 2026 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import SwiftUI +import WireDesign + +/// Emoji picker strip shown when the user opens the reaction tray during a call. +/// - `.vertical` axis: stacked column used in landscape alongside the action buttons. +/// - `.horizontal` axis: scrollable row used in portrait above the action buttons. +struct CallReactionsTray: View { + + static let presetEmojis = ["👍", "🎉", "❤️", "👏", "😊"] + private static let maxRecentCount = 5 + + let axis: Axis + var onEmojiTap: (String) -> Void + var onOpenPicker: () -> Void + + @State private var recentEmojis: [String] = [] + + private var allEmojis: [String] { + let presets = Self.presetEmojis + let recents = recentEmojis.filter { !presets.contains($0) }.prefix(Self.maxRecentCount) + return presets + recents + } + + var body: some View { + if axis == .vertical { + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 8) { + ForEach(allEmojis, id: \.self) { emojiTile($0) } + moreButton + } + .padding(.vertical, 16) + } + .onAppear { loadRecentEmojis() } + } else { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(allEmojis, id: \.self) { emojiTile($0) } + moreButton + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + .onAppear { loadRecentEmojis() } + } + } + + private func emojiTile(_ emoji: String) -> some View { + Button { + registerRecent(emoji) + onEmojiTap(emoji) + } label: { + Text(emoji) + .font(.system(size: 32)) + .frame(width: 48, height:48) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(ColorTheme.Buttons.Secondary.enabled.color) + ) + } + } + + private var moreButton: some View { + Button { + onOpenPicker() + } label: { + Image(systemName: "face.smiling") + .font(.system(size: 32)) + .foregroundStyle(ColorTheme.Buttons.Secondary.enabledOutline.color) + .frame(width: 48, height: 48) + + } + } + + private func loadRecentEmojis() { + guard recentEmojis.isEmpty else { return } + recentEmojis = EmojiRepository().fetchRecentlyUsedEmojis().map(\.value) + } + + private func registerRecent(_ emoji: String) { + let repo = EmojiRepository() + var updated = [emoji] + recentEmojis.filter { $0 != emoji } + if updated.count > 15 { updated = Array(updated.prefix(15)) } + repo.registerRecentlyUsedEmojis(updated) + recentEmojis = updated + } +} + +// MARK: - Preview + +#Preview("Horizontal (portrait)") { + VStack { + Spacer() + CallReactionsTray(axis: .horizontal, onEmojiTap: { print("tapped \($0)") }, onOpenPicker: {}) + Spacer() + } + .background(Color(.systemGray5)) +} + +#Preview("Vertical (landscape)") { + HStack { + Spacer() + CallReactionsTray(axis: .vertical, onEmojiTap: { print("tapped \($0)") }, onOpenPicker: {}) + .frame(width: 72) + .background(Color(.systemBackground)) + Spacer() + } + .frame(height: 400) + .background(Color(.systemGray5)) +} diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/AdaptiveContainer/CallPanelView.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/AdaptiveContainer/CallPanelView.swift new file mode 100644 index 00000000000..0daf90e42c4 --- /dev/null +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/AdaptiveContainer/CallPanelView.swift @@ -0,0 +1,202 @@ +// +// Wire +// Copyright (C) 2026 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import SwiftUI +import WireDesign + +enum CallPanelLayout { + case portrait + case landscape(side: HorizontalEdge) +} + +/// Unified call panel used in both portrait (bottom sheet) and landscape (side panel). +/// Portrait: VStack — horizontal drag handle | horizontal buttons row | participants list. +/// Landscape: HStack — vertical drag handle | vertical buttons column | participants list. +struct CallPanelView: View { + + // Landscape constants + static let buttonsColumnWidth: CGFloat = 72 + static let participantsColumnWidth: CGFloat = 320 + static let handleWidth: CGFloat = 24 + + // Portrait constants (mirror landscape dimensions) + static let handleHeight: CGFloat = 24 + static let buttonsRowHeight: CGFloat = 72 + static let emojiTrayHeight: CGFloat = 72 + + @ObservedObject var viewModel: CallingContainerViewModel + @Binding var isExpanded: Bool + @Binding var isReactionsTrayOpen: Bool + var layout: CallPanelLayout = .portrait + + var body: some View { + switch layout { + case .portrait: + portraitBody + case .landscape(let side): + landscapeBody(side: side) + } + } + + // MARK: - Portrait layout + + private var portraitBody: some View { + VStack(spacing: 0) { + portraitDragHandle + if isReactionsTrayOpen { + CallReactionsTray(axis: .horizontal, onEmojiTap: { emoji in + perform(.sendReaction(emoji: emoji)) + }, onOpenPicker: { + perform(.openEmojiPicker) + }) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + + portraitCallButtonsRow + portraitParticipantsSection + } + .background(Color(SemanticColors.View.backgroundDefault)) + .animation(.easeInOut(duration: 0.2), value: isReactionsTrayOpen) + } + + private var portraitDragHandle: some View { + HStack { + Spacer() + RoundedRectangle(cornerRadius: 3) + .fill(Color(SemanticColors.View.backgroundCallDragBarIndicator)) + .frame(width: 44, height: 5) + Spacer() + } + .frame(height: Self.handleHeight) + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + isExpanded.toggle() + } + } + } + + @ViewBuilder + private var portraitCallButtonsRow: some View { + if let configuration = viewModel.callInfoConfiguration { + ActiveCallActionsView( + axis: .horizontal, + isReactionsTrayOpen: isReactionsTrayOpen, + configuration: CallActionsConfiguration(configuration), + performAction: perform + ) + .frame(height: Self.buttonsRowHeight) + } + } + + private var portraitParticipantsSection: some View { + CallingActionsInfoRepresentable( + viewModel: viewModel, + isExpanded: $isExpanded, + hideActionsView: true + ) + } + + // MARK: - Landscape layout + + private func landscapeBody(side: HorizontalEdge) -> some View { + HStack(spacing: 0) { + if side == .trailing { + landscapeDragHandle + if isReactionsTrayOpen { + emojiColumn + .transition(.move(edge: .trailing).combined(with: .opacity)) + } + callButtonsColumn + participantsColumn + } else { + participantsColumn + callButtonsColumn + if isReactionsTrayOpen { + emojiColumn + .transition(.move(edge: .leading).combined(with: .opacity)) + } + landscapeDragHandle + } + } + .background(Color(SemanticColors.View.backgroundDefault)) + .animation(.easeInOut(duration: 0.2), value: isReactionsTrayOpen) + } + + private var emojiColumn: some View { + CallReactionsTray(axis: .vertical, + onEmojiTap: { emoji in + perform(.sendReaction(emoji: emoji)) + }, onOpenPicker: { + perform(.openEmojiPicker) + }) + .frame(width: Self.buttonsColumnWidth) + .frame(maxHeight: .infinity) + .ignoresSafeArea() + } + + private var landscapeDragHandle: some View { + VStack { + Spacer() + RoundedRectangle(cornerRadius: 3) + .fill(Color(SemanticColors.View.backgroundCallDragBarIndicator)) + .frame(width: 5, height: 44) + Spacer() + } + .frame(width: Self.handleWidth) + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + isExpanded.toggle() + } + } + } + + @ViewBuilder + private var callButtonsColumn: some View { + if let configuration = viewModel.callInfoConfiguration { + ActiveCallActionsView( + axis: .vertical, + isReactionsTrayOpen: isReactionsTrayOpen, + configuration: CallActionsConfiguration(configuration), + performAction: perform + ) + .frame(width: Self.buttonsColumnWidth) + .frame(maxHeight: .infinity) + .ignoresSafeArea() + } + } + + private var participantsColumn: some View { + CallingActionsInfoRepresentable( + viewModel: viewModel, + isExpanded: $isExpanded, + hideActionsView: true + ) + .frame(width: Self.participantsColumnWidth) + } + + private func perform(_ action: CallAction) { + switch action { + case .toggleReactionsTray: + withAnimation(.easeInOut(duration: 0.2)) { isReactionsTrayOpen.toggle() } + default: + viewModel.callViewController?.callingActionsViewPerformAction(action) + } + } +} diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/AdaptiveContainer/CallViewControllerRepresentable.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/AdaptiveContainer/CallViewControllerRepresentable.swift new file mode 100644 index 00000000000..598f7dcae77 --- /dev/null +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/AdaptiveContainer/CallViewControllerRepresentable.swift @@ -0,0 +1,69 @@ +// +// Wire +// Copyright (C) 2026 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import SwiftUI +import WireDataModel + +struct CallViewControllerRepresentable: UIViewControllerRepresentable { + + let viewModel: CallingContainerViewModel + + func makeCoordinator() -> Coordinator { + Coordinator(viewModel: viewModel) + } + + func makeUIViewController(context: Context) -> CallViewController { + let vc = CallViewController( + voiceChannel: viewModel.voiceChannel, + selfUser: viewModel.userSession.selfUser, + isOverlayEnabled: false, + userSession: viewModel.userSession + ) + vc.configurationObserver = context.coordinator + vc.delegate = context.coordinator + viewModel.callViewController = vc + return vc + } + + func updateUIViewController(_ vc: CallViewController, context: Context) {} +} + +// MARK: - Coordinator + +extension CallViewControllerRepresentable { + + final class Coordinator: NSObject, CallInfoConfigurationObserver, CallViewControllerDelegate { + + private let viewModel: CallingContainerViewModel + + init(viewModel: CallingContainerViewModel) { + self.viewModel = viewModel + } + + func didUpdateConfiguration(configuration: CallInfoConfiguration) { + viewModel.didUpdateConfiguration(configuration) + } + + func callViewControllerDidDisappear( + _ callController: CallViewController, + for conversation: ZMConversation? + ) { + viewModel.onHideCallView() + } + } +} diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/AdaptiveContainer/CallingActionsInfoRepresentable.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/AdaptiveContainer/CallingActionsInfoRepresentable.swift new file mode 100644 index 00000000000..3bcdf2cd261 --- /dev/null +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/AdaptiveContainer/CallingActionsInfoRepresentable.swift @@ -0,0 +1,44 @@ +// +// Wire +// Copyright (C) 2026 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import SwiftUI + +/// UIKit bridge that renders the participants list (security level + header + scrollable list). +/// Always used with `hideActionsView: true`; call action buttons live in `CallPanelView`. +struct CallingActionsInfoRepresentable: UIViewControllerRepresentable { + + let viewModel: CallingContainerViewModel + @Binding var isExpanded: Bool + var hideActionsView: Bool = false + + func makeUIViewController(context: Context) -> CallingActionsInfoViewController { + let vc = CallingActionsInfoViewController( + participants: viewModel.participants, + selfUser: viewModel.userSession.selfUser + ) + vc.additionalSafeAreaInsets = .zero + return vc + } + + func updateUIViewController(_ vc: CallingActionsInfoViewController, context: Context) { + vc.participants = viewModel.participants + if let configuration = viewModel.callInfoConfiguration { + vc.didUpdateConfiguration(configuration: configuration) + } + } +} diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/AdaptiveContainer/CallingContainerView.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/AdaptiveContainer/CallingContainerView.swift new file mode 100644 index 00000000000..0fe05e63490 --- /dev/null +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/AdaptiveContainer/CallingContainerView.swift @@ -0,0 +1,156 @@ +// +// Wire +// Copyright (C) 2026 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import Combine +import SwiftUI +import UIKit + +struct CallingContainerView: View { + + @ObservedObject var viewModel: CallingContainerViewModel + @State private var isExpanded: Bool = false + @State private var dragOffset: CGFloat = 0 + @State private var panelSide: HorizontalEdge = UIDevice.current.twoDimensionOrientation == .landscapeRight ? .leading : .trailing + @State private var isReactionsTrayOpen = false + + private var landscapePanelWidth: CGFloat { + let base = CallPanelView.handleWidth + CallPanelView.buttonsColumnWidth + CallPanelView.participantsColumnWidth + return base + (isReactionsTrayOpen ? CallPanelView.buttonsColumnWidth : 0) + } + private var landscapeCollapsedWidth: CGFloat { + CallPanelView.handleWidth + CallPanelView.buttonsColumnWidth + } + + var body: some View { + GeometryReader { geo in + let isLandscape = geo.size.width > geo.size.height + VStack(spacing: 0) { + CallHeaderBar( + state: viewModel.callHeaderState, + onMinimize: viewModel.onHideCallView + ) + ZStack(alignment: .topLeading) { + CallViewControllerRepresentable(viewModel: viewModel) + .id(viewModel.voiceChannelRevision) + .ignoresSafeArea() + if isLandscape { + landscapePanel() + } else { + portraitPanel(geo: geo) + } + } + } + .onChange(of: isLandscape) { _ in + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + isExpanded = false + dragOffset = 0 + isReactionsTrayOpen = false + } + } + } + .ignoresSafeArea(edges: .bottom) + .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in + panelSide = UIDevice.current.twoDimensionOrientation == .landscapeRight ? .leading : .trailing + } + } + + // MARK: - Portrait panel + + private func portraitPanel(geo: GeometryProxy) -> some View { + let peekHeight = CallPanelView.handleHeight + CallPanelView.buttonsRowHeight + geo.safeAreaInsets.bottom + let maxHeight = geo.size.height * 0.7 + let travel = maxHeight - peekHeight + let trayShift = isReactionsTrayOpen ? CallPanelView.emojiTrayHeight : 0 + let currentOffset = isExpanded ? 0 : (travel - trayShift) + let effectiveDrag = viewModel.isPanEnabled ? dragOffset : 0 + + return VStack(spacing: 0) { + Spacer() + CallPanelView(viewModel: viewModel, isExpanded: $isExpanded, isReactionsTrayOpen: $isReactionsTrayOpen, layout: .portrait) + .frame(height: maxHeight) + .offset(y: currentOffset + effectiveDrag) + .gesture(viewModel.isPanEnabled ? portraitDragGesture(travel: travel) : nil) + } + } + + private func portraitDragGesture(travel: CGFloat) -> some Gesture { + DragGesture() + .onChanged { value in + let t = value.translation.height + dragOffset = isExpanded ? max(0, min(travel, t)) : max(-travel, min(0, t)) + } + .onEnded { value in + let t = value.translation.height + let velocity = value.predictedEndTranslation.height - value.translation.height + let shouldExpand: Bool + if isExpanded { + shouldExpand = t < travel / 2 && velocity < 1000 + } else { + shouldExpand = t < -(travel / 2) || velocity < -1000 + } + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + isExpanded = shouldExpand + dragOffset = 0 + } + } + } + + // MARK: - Landscape panel + + private func landscapePanel() -> some View { + let travel = CallPanelView.participantsColumnWidth + let currentOffset = isExpanded ? 0 : (panelSide == .trailing ? travel : -travel) + let effectiveDrag = viewModel.isPanEnabled ? dragOffset : 0 + + return HStack(spacing: 0) { + if panelSide == .trailing { Spacer() } + CallPanelView(viewModel: viewModel, isExpanded: $isExpanded, isReactionsTrayOpen: $isReactionsTrayOpen, layout: .landscape(side: panelSide)) + .frame(width: landscapePanelWidth) + .offset(x: currentOffset + effectiveDrag) + .gesture(viewModel.isPanEnabled ? landscapeDragGesture(travel: travel) : nil) + if panelSide == .leading { Spacer() } + } + .ignoresSafeArea() + } + + private func landscapeDragGesture(travel: CGFloat) -> some Gesture { + DragGesture() + .onChanged { value in + let t = value.translation.width + if panelSide == .trailing { + dragOffset = isExpanded ? max(0, min(travel, t)) : max(-travel, min(0, t)) + } else { + dragOffset = isExpanded ? max(-travel, min(0, t)) : max(0, min(travel, t)) + } + } + .onEnded { value in + let t = value.translation.width + let velocity = value.predictedEndTranslation.width - value.translation.width + let shouldExpand: Bool + if panelSide == .trailing { + shouldExpand = isExpanded ? (t < travel / 2 && velocity < 1000) : (t < -(travel / 2) || velocity < -1000) + } else { + shouldExpand = isExpanded ? (t > -(travel / 2) && velocity > -1000) : (t > travel / 2 || velocity > 1000) + } + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + isExpanded = shouldExpand + dragOffset = 0 + } + } + } +} diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/AdaptiveContainer/CallingContainerViewController.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/AdaptiveContainer/CallingContainerViewController.swift new file mode 100644 index 00000000000..29992c8b8b6 --- /dev/null +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/AdaptiveContainer/CallingContainerViewController.swift @@ -0,0 +1,84 @@ +// +// Wire +// Copyright (C) 2026 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import SwiftUI +import UIKit +import WireDataModel + +protocol ActiveCallViewControllerDelegate: AnyObject { + func activeCallViewControllerDidDisappear( + _ activeCallViewController: UIViewController, + for conversation: ZMConversation? + ) +} + +final class CallingContainerViewController: UIHostingController { + + weak var delegate: ActiveCallViewControllerDelegate? + + private let viewModel: CallingContainerViewModel + private let callDegradationController = CallDegradationController() + + init(viewModel: CallingContainerViewModel) { + self.viewModel = viewModel + super.init(rootView: CallingContainerView(viewModel: viewModel)) + + viewModel.onHideCallView = { [weak self] in + guard let self else { return } + delegate?.activeCallViewControllerDidDisappear(self, for: viewModel.voiceChannel.conversation) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + viewModel.startObserving() + setupCallDegradationController() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + viewModel.callViewController?.reloadGrid() + } + + private func setupCallDegradationController() { + addChild(callDegradationController) + view.addSubview(callDegradationController.view) + callDegradationController.view.fitIn(view: view) + callDegradationController.didMove(toParent: self) + callDegradationController.targetViewController = self + callDegradationController.delegate = self + } +} + +// MARK: - CallDegradationControllerDelegate + +extension CallingContainerViewController: CallDegradationControllerDelegate { + + func continueDegradedCall() { + viewModel.callViewController?.callingActionsViewPerformAction(.continueDegradedCall) + } + + func cancelDegradedCall() { + viewModel.callViewController?.callingActionsViewPerformAction(.terminateDegradedCall) + } +} diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/AdaptiveContainer/CallingContainerViewModel.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/AdaptiveContainer/CallingContainerViewModel.swift new file mode 100644 index 00000000000..b6b9a5635b4 --- /dev/null +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/AdaptiveContainer/CallingContainerViewModel.swift @@ -0,0 +1,160 @@ +// +// Wire +// Copyright (C) 2026 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import Combine +import WireDataModel +import WireLogging +import WireSyncEngine + +final class CallingContainerViewModel: ObservableObject { + + @Published var callInfoConfiguration: CallInfoConfiguration? + @Published var callHeaderState: CallHeaderState = .init() + @Published var isIncomingCall: Bool = false + @Published var isPanEnabled: Bool = true + @Published var participants: CallParticipantsList = [] + /// Bumped when the active voice channel changes; used as `.id` on CallViewControllerRepresentable to force recreation. + @Published private(set) var voiceChannelRevision: UUID = UUID() + + var onHideCallView: () -> Void = {} + + private(set) var voiceChannel: VoiceChannel + let userSession: UserSession + /// Set by CallViewControllerRepresentable after the CallViewController is created. + weak var callViewController: CallViewController? + + private var callStateObserverToken: Any? + private var participantsObserverToken: Any? + private weak var callDurationTimer: Timer? + + init(voiceChannel: VoiceChannel, userSession: UserSession) { + self.voiceChannel = voiceChannel + self.userSession = userSession + self.participants = voiceChannel.getParticipantsList() + } + + deinit { + stopCallDurationTimer() + } + + func startObserving() { + guard userSession is ZMUserSession else { + WireLogger.calling.error("UserSession not available when initializing \(type(of: self))") + return + } + callStateObserverToken = WireCallCenterV3.addCallStateObserver( + observer: self, + contextProvider: userSession.contextProvider + ) + participantsObserverToken = voiceChannel.addParticipantObserver(self) + } + + func didUpdateConfiguration(_ configuration: CallInfoConfiguration) { + let stateChanged = configuration.state != callInfoConfiguration?.state + if stateChanged { + switch configuration.state { + case .established: startCallDurationTimer() + case .terminating: stopCallDurationTimer() + default: break + } + } + DispatchQueue.main.async { [weak self] in + self?.apply(configuration) + } + } + + private func apply(_ configuration: CallInfoConfiguration) { + callInfoConfiguration = configuration + callHeaderState = CallHeaderState( + title: configuration.title, + statusString: configuration.displayString, + shouldShowBitrateLabel: configuration.shouldShowBitrateLabel, + isConstantBitRate: configuration.isConstantBitRate + ) + isIncomingCall = configuration.state.isIncoming + isPanEnabled = !configuration.state.isIncoming + } + + func updateVoiceChannelIfNeeded() { + guard + let conversation = (userSession as? ZMUserSession)?.priorityCallConversation, + voiceChannel.conversation != conversation, + let newVoiceChannel = conversation.voiceChannel + else { return } + + voiceChannel = newVoiceChannel + participants = newVoiceChannel.getParticipantsList() + participantsObserverToken = newVoiceChannel.addParticipantObserver(self) + voiceChannelRevision = UUID() + } + + private func startCallDurationTimer() { + stopCallDurationTimer() + callDurationTimer = .scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in + guard let self, + let configuration = callInfoConfiguration, + case .established = configuration.state else { return } + callHeaderState.statusString = configuration.displayString + } + } + + private func stopCallDurationTimer() { + callDurationTimer?.invalidate() + callDurationTimer = nil + } +} + +// MARK: - WireCallCenterCallStateObserver + +extension CallingContainerViewModel: WireCallCenterCallStateObserver { + func callCenterDidChange( + callState: CallState, + conversation: ZMConversation, + caller: UserType, + timestamp: Date?, + previousCallState: CallState? + ) { + updateVoiceChannelIfNeeded() + } +} + +// MARK: - WireCallCenterCallParticipantObserver + +extension CallingContainerViewModel: WireCallCenterCallParticipantObserver { + func callParticipantsDidChange(conversation: ZMConversation, participants: [CallParticipant]) { + self.participants = voiceChannel.getParticipantsList() + } +} + +// MARK: - VoiceChannel participants helper + +extension VoiceChannel { + func getParticipantsList() -> CallParticipantsList { + let sorted = participants( + ofKind: .all, + activeSpeakersLimit: CallInfoConfiguration.maxActiveSpeakers + ) + return sorted.map { + CallParticipantsListCellConfiguration.callParticipant( + user: HashBox(value: $0.user), + callParticipantState: $0.state, + activeSpeakerState: $0.activeSpeakerState + ) + } + } +} diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/BottomSheetContainerViewController/BottomSheetContainerViewController.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/BottomSheetContainerViewController/BottomSheetContainerViewController.swift index 5542a0c78d7..df91c7002d6 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/BottomSheetContainerViewController/BottomSheetContainerViewController.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/BottomSheetContainerViewController/BottomSheetContainerViewController.swift @@ -18,6 +18,7 @@ import UIKit +@available(*, deprecated, renamed: "CallingContainerViewController") class BottomSheetContainerViewController: UIViewController { // MARK: - Configuration diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/BottomSheetContainerViewController/CallHeaderBar.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/BottomSheetContainerViewController/CallHeaderBar.swift index b10cf23bc59..29c6b18c2aa 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/BottomSheetContainerViewController/CallHeaderBar.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/BottomSheetContainerViewController/CallHeaderBar.swift @@ -16,72 +16,55 @@ // along with this program. If not, see http://www.gnu.org/licenses/. // -import UIKit +import SwiftUI import WireDesign import WireLocators -final class CallHeaderBar: UIView { - private let verticalStackView = UIStackView(axis: .vertical) - private let titleLabel = DynamicFontLabel(fontSpec: .normalSemiboldFont, color: SemanticColors.Label.textDefault) - private let timeLabel = DynamicFontLabel(fontSpec: .smallRegularFont, color: SemanticColors.Label.textDefault) - private let bitrateLabel = BitRateLabel( - fontSpec: .smallRegularFont, - color: SemanticColors.Label.textCollectionSecondary - ) - let minimalizeButton = UIButton() +struct CallHeaderState: Equatable { + var title: String = "" + var statusString: String = "" + var shouldShowBitrateLabel: Bool = false + var isConstantBitRate: Bool = false +} - init() { - super.init(frame: .zero) - setupViews() - setupConstraints() - } +struct CallHeaderBar: View { - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + let state: CallHeaderState + let onMinimize: () -> Void - private func setupViews() { - backgroundColor = SemanticColors.View.backgroundDefault - minimalizeButton.setImage(UIImage(systemName: "chevron.down"), for: .normal) - minimalizeButton.tintColor = SemanticColors.View.backgroundDefaultBlack - minimalizeButton.accessibilityLabel = L10n.Accessibility.Calling.HeaderBar.description - [minimalizeButton, titleLabel].forEach { $0.translatesAutoresizingMaskIntoConstraints = false } - verticalStackView.translatesAutoresizingMaskIntoConstraints = false - addSubview(minimalizeButton) - addSubview(verticalStackView) - titleLabel.accessibilityTraits = .header - verticalStackView.alignment = .center - verticalStackView.spacing = 0.0 - verticalStackView.addArrangedSubview(titleLabel) - verticalStackView.addArrangedSubview(timeLabel) - verticalStackView.addArrangedSubview(bitrateLabel) + var body: some View { + ZStack(alignment: .leading) { + VStack(spacing: 0) { + Text(state.title) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(Color(uiColor: SemanticColors.Label.textDefault)) + .accessibilityAddTraits(.isHeader) - bitrateLabel.accessibilityIdentifier = "bitrate-indicator" - timeLabel.accessibilityIdentifier = Locators.OngoingCallPage.timeLabel.rawValue - } + Text(state.statusString) + .font(.system(size: 11, weight: .regular)) + .foregroundColor(Color(uiColor: SemanticColors.Label.textDefault)) + .accessibilityIdentifier(Locators.OngoingCallPage.timeLabel.rawValue) - private func setupConstraints() { - NSLayoutConstraint.activate([ - verticalStackView.centerXAnchor.constraint(equalTo: centerXAnchor), - verticalStackView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 10.0), - verticalStackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -6.0), - minimalizeButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20.0), - minimalizeButton.centerYAnchor.constraint(equalTo: centerYAnchor), - minimalizeButton.widthAnchor.constraint(equalToConstant: 32.0), - minimalizeButton.heightAnchor.constraint(equalToConstant: 32.0), - verticalStackView.leadingAnchor.constraint( - greaterThanOrEqualTo: minimalizeButton.trailingAnchor, - constant: 6.0 - ), - verticalStackView.heightAnchor.constraint(greaterThanOrEqualToConstant: 32.0) - ]) - } + if state.shouldShowBitrateLabel, BitRateStatus(state.isConstantBitRate) == .constant { + Text(L10n.Localizable.Call.Status.constantBitrate) + .font(.system(size: 11, weight: .regular)) + .foregroundColor(Color(uiColor: SemanticColors.Label.textCollectionSecondary)) + .accessibilityIdentifier("bitrate-indicator") + .accessibilityValue(BitRateStatus.constant.rawValue) + } + } + .frame(maxWidth: .infinity, minHeight: 32) - func updateConfiguration(configuration: CallStatusViewInputType) { - titleLabel.text = configuration.title - timeLabel.text = configuration.displayString - bitrateLabel.isHidden = !configuration.shouldShowBitrateLabel - bitrateLabel.bitRateStatus = BitRateStatus(configuration.isConstantBitRate) + Button(action: onMinimize) { + Image(systemName: "chevron.down") + .foregroundColor(Color(uiColor: SemanticColors.View.backgroundDefaultBlack)) + } + .frame(width: 32, height: 32) + .padding(.leading, 20) + .accessibilityLabel(L10n.Accessibility.Calling.HeaderBar.description) + } + .padding(.top, 10) + .padding(.bottom, 6) + .background(Color(uiColor: SemanticColors.View.backgroundDefault)) } } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/BottomSheetContainerViewController/CallingActionsInfoViewController/CallingActionsInfoViewController.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/BottomSheetContainerViewController/CallingActionsInfoViewController/CallingActionsInfoViewController.swift index f352b8669b4..50f22b714a3 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/BottomSheetContainerViewController/CallingActionsInfoViewController/CallingActionsInfoViewController.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/BottomSheetContainerViewController/CallingActionsInfoViewController/CallingActionsInfoViewController.swift @@ -20,14 +20,12 @@ import UIKit import WireDataModel import WireDesign -protocol CallingActionsInfoViewControllerDelegate: AnyObject { - func actionsViewHeightChanged(to height: CGFloat) -} - +/// Participants-list container used by `CallingActionsInfoRepresentable`. +/// Renders security level banner, participants header, and scrollable participants list. +/// Call action buttons are owned by `CallPanelView` in both portrait and landscape. final class CallingActionsInfoViewController: UIViewController, UICollectionViewDelegateFlowLayout { private let participantsHeaderHeight: CGFloat = 42 private let cellHeight: CGFloat = 56 - private var topConstraint: NSLayoutConstraint? private let selfUser: UserType private var collectionView: CallParticipantsListView! @@ -38,11 +36,6 @@ final class CallingActionsInfoViewController: UIViewController, UICollectionView fontSpec: .smallSemiboldFont, color: SemanticColors.Label.textSectionHeader ) - private(set) var actionsViewHeightConstraint: NSLayoutConstraint! - - weak var delegate: CallingActionsInfoViewControllerDelegate? - var isIncomingCall: Bool = false - let actionsView = CallingActionsView() var participants: CallParticipantsList { didSet { @@ -74,11 +67,6 @@ final class CallingActionsInfoViewController: UIViewController, UICollectionView override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() updateRows() - updateActionViewHeight() - } - - func setCallingActionsViewDelegate(actionsDelegate: CallingActionsViewDelegate?) { - actionsView.delegate = actionsDelegate } private func setupViews() { @@ -88,6 +76,7 @@ final class CallingActionsInfoViewController: UIViewController, UICollectionView stackView.alignment = .center stackView.distribution = .fill stackView.spacing = 0 + let collectionViewLayout = UICollectionViewFlowLayout() collectionViewLayout.scrollDirection = .vertical collectionViewLayout.minimumInteritemSpacing = 12 @@ -108,27 +97,20 @@ final class CallingActionsInfoViewController: UIViewController, UICollectionView [ securityLevelView, - actionsView, participantsHeaderView, collectionView ].forEach(stackView.addArrangedSubview) CallParticipantsListCellConfiguration.prepare(collectionView) - view.backgroundColor = SemanticColors.View.backgroundDefaultWhite + view.backgroundColor = SemanticColors.View.backgroundDefault } private func createConstraints() { - actionsViewHeightConstraint = actionsView.heightAnchor.constraint(equalToConstant: calculateHeightConstant()) - NSLayoutConstraint.activate([ - stackView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), stackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), - stackView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), - stackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -20), - - actionsView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), - actionsView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), - actionsViewHeightConstraint, + stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor), participantsHeaderView.heightAnchor.constraint(equalToConstant: participantsHeaderHeight), participantsHeaderLabel.leadingAnchor.constraint( @@ -137,48 +119,14 @@ final class CallingActionsInfoViewController: UIViewController, UICollectionView ), participantsHeaderLabel.centerYAnchor.constraint(equalTo: participantsHeaderView.centerYAnchor), - collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), - collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), - participantsHeaderView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), - participantsHeaderView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + participantsHeaderView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + participantsHeaderView.trailingAnchor.constraint(equalTo: view.trailingAnchor), securityLevelView.widthAnchor.constraint(equalTo: stackView.widthAnchor) ]) } - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - super.viewWillTransition(to: size, with: coordinator) - updateActionViewHeight() - } - - func updateActionViewHeight() { - let height = calculateHeightConstant() - actionsViewHeightConstraint.constant = height - delegate?.actionsViewHeightChanged(to: height) - actionsView.verticalStackView.alignment = determineStackViewAlignment() - } - - private func calculateHeightConstant() -> CGFloat { - var baseHeight: CGFloat = if UIDevice.current.twoDimensionOrientation.isLandscape { - 128 - } else { - isIncomingCall ? 250 : 128 - } - - if !securityLevelView.isHidden { - baseHeight += SecurityLevelView.securityLevelViewHeight - } - - return baseHeight + view.safeAreaInsets.bottom - } - - private func determineStackViewAlignment() -> UIStackView.Alignment { - if UIDevice.current.twoDimensionOrientation.isLandscape { - .center - } else { - .fill - } - } - private func updateRows() { collectionView?.rows = participants } @@ -198,18 +146,12 @@ final class CallingActionsInfoViewController: UIViewController, UICollectionView func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { false } - } // MARK: - CallInfoConfigurationObserver extension CallingActionsInfoViewController: CallInfoConfigurationObserver { func didUpdateConfiguration(configuration: CallInfoConfiguration) { - isIncomingCall = configuration.state.isIncoming - actionsView.isIncomingCall = isIncomingCall - actionsView.update(with: configuration) securityLevelView.configure(with: configuration.classification) - - updateActionViewHeight() } } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/BottomSheetContainerViewController/CallingActionsView/CallingActionButton.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/BottomSheetContainerViewController/CallingActionsView/CallingActionButton.swift index b34e3bb9f89..f2456750e30 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/BottomSheetContainerViewController/CallingActionsView/CallingActionButton.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/BottomSheetContainerViewController/CallingActionsView/CallingActionButton.swift @@ -31,6 +31,22 @@ class CallingActionButton: IconLabelButton { subtitleTransformLabel.font = titleLabel?.font iconButton.setIcon(input.icon(forState: .normal), size: iconSize, for: .normal) iconButton.setIcon(input.icon(forState: .selected), size: iconSize, for: .selected) + + hideLabel() + } + + private func hideLabel() { + subtitleTransformLabel.isHidden = true + subtitleTransformLabel.text = nil + // Swap the label's bottom constraint for an iconButton.bottom constraint so + // the button height collapses to the icon size only. + constraints + .filter { + ($0.firstItem === subtitleTransformLabel && $0.firstAttribute == .bottom) || + ($0.secondItem === subtitleTransformLabel && $0.secondAttribute == .bottom) + } + .forEach { $0.isActive = false } + iconButton.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true } override func apply(_ configuration: CallActionAppearance) { diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/BottomSheetContainerViewController/CallingActionsView/CallingActionsView.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/BottomSheetContainerViewController/CallingActionsView/CallingActionsView.swift index 657e1c3d77d..db68cc0a35b 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/BottomSheetContainerViewController/CallingActionsView/CallingActionsView.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/BottomSheetContainerViewController/CallingActionsView/CallingActionsView.swift @@ -53,7 +53,6 @@ final class CallingActionsView: UIView { private let microphoneButton = CallingActionButton.microphoneButton() private let cameraButton = CallingActionButton.cameraButton() private let speakerButton = CallingActionButton.speakerButton() - private let flipCameraButton = CallingActionButton.flipCameraButton() private let endCallButton = EndCallButton.endCallButton() private let handleView = AccessibilityActionView() private let handleContainerView = UIView() @@ -65,7 +64,6 @@ final class CallingActionsView: UIView { microphoneButton, cameraButton, speakerButton, - flipCameraButton, endCallButton ] } @@ -134,7 +132,6 @@ final class CallingActionsView: UIView { ].forEach(verticalStackView.addArrangedSubview) [ - flipCameraButton, cameraButton, microphoneButton, speakerButton, @@ -158,10 +155,10 @@ final class CallingActionsView: UIView { handleView.topAnchor.constraint(equalTo: handleContainerView.topAnchor), handleView.bottomAnchor.constraint(equalTo: handleContainerView.bottomAnchor), handleView.heightAnchor.constraint(equalToConstant: 5), - handleView.widthAnchor.constraint(equalToConstant: 130), + handleView.widthAnchor.constraint(equalToConstant: CallPanelView.handleWidth), topStackView.leadingAnchor.constraint(equalTo: verticalStackView.leadingAnchor, constant: 14), topStackView.trailingAnchor.constraint(equalTo: verticalStackView.trailingAnchor, constant: -14), - topStackView.heightAnchor.constraint(equalToConstant: 85).withPriority(.required) + topStackView.heightAnchor.constraint(equalToConstant: 64).withPriority(.required) ]) } @@ -237,7 +234,6 @@ final class CallingActionsView: UIView { videoButtonDisabledTapRecognizer?.isEnabled = !input.canToggleMediaType cameraButton.isEnabled = input.canToggleMediaType cameraButton.isSelected = input.mediaState.isSendingVideo && input.permissions.canAcceptVideoCalls - flipCameraButton.isEnabled = input.mediaState.isSendingVideo && input.permissions.canAcceptVideoCalls speakerButton.isSelected = input.mediaState.isSpeakerEnabled speakerButton.isEnabled = canToggleSpeakerButton(input) updateAccessibilityElements(with: input) @@ -266,7 +262,6 @@ final class CallingActionsView: UIView { case cameraButton: .toggleVideoState case videoButtonDisabledTapRecognizer: .alertVideoUnavailable case speakerButton: .toggleSpeakerState - case flipCameraButton: .flipCamera case endCallButton, largeHangUpButton: .terminateCall case largePickUpButton: .acceptCall default: fatalError("Unexpected Button: \(button)") @@ -285,8 +280,6 @@ final class CallingActionsView: UIView { endCallButton.accessibilityLabel = Calling.HangUpButton.description cameraButton.accessibilityLabel = input.mediaState.isSendingVideo ? Calling.VideoOffButton.description : Calling .VideoOnButton.description - flipCameraButton.accessibilityLabel = input.cameraType == .front ? Calling.FlipCameraBackButton - .description : Calling.FlipCameraFrontButton.description largePickUpButton.accessibilityLabel = Calling.AcceptButton.description largeHangUpButton.accessibilityLabel = Calling.HangUpButton.description } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/BottomSheetContainerViewController/CallingBottomSheetViewController.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/BottomSheetContainerViewController/CallingBottomSheetViewController.swift deleted file mode 100644 index b4e19590254..00000000000 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/BottomSheetContainerViewController/CallingBottomSheetViewController.swift +++ /dev/null @@ -1,357 +0,0 @@ -// -// Wire -// Copyright (C) 2026 Wire Swiss GmbH -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, see http://www.gnu.org/licenses/. -// - -import avs -import UIKit -import WireDataModel -import WireDesign -import WireLogging -import WireSyncEngine - -protocol ActiveCallViewControllerDelegate: AnyObject { - func activeCallViewControllerDidDisappear( - _ activeCallViewController: UIViewController, - for conversation: ZMConversation? - ) -} - -protocol CallInfoConfigurationObserver: AnyObject { - func didUpdateConfiguration(configuration: CallInfoConfiguration) -} - -final class CallingBottomSheetViewController: BottomSheetContainerViewController { - private let bottomSheetMaxHeight = UIScreen.main.bounds.height * 0.7 - - weak var delegate: ActiveCallViewControllerDelegate? - private var participantsObserverToken: Any? - private var voiceChannel: VoiceChannel - private let headerBar = CallHeaderBar() - private let overlay = PassThroughOpaqueView() - private var callStateObserverToken: Any? - private weak var callDurationTimer: Timer? - private var callInfoConfiguration: CallInfoConfiguration? - private let callDegradationController = CallDegradationController() - - var bottomSheetMinimalOffset: CGFloat { - callingActionsInfoViewController.actionsViewHeightConstraint.constant - } - - let userSession: UserSession - - let callingActionsInfoViewController: CallingActionsInfoViewController - var visibleVoiceChannelViewController: CallViewController { - didSet { - transition(to: visibleVoiceChannelViewController, from: oldValue) - } - } - - init(voiceChannel: VoiceChannel, userSession: UserSession) { - self.voiceChannel = voiceChannel - self.userSession = userSession - - self.visibleVoiceChannelViewController = CallViewController( - voiceChannel: voiceChannel, - selfUser: userSession.selfUser, - isOverlayEnabled: false, - userSession: userSession - ) - self.callingActionsInfoViewController = CallingActionsInfoViewController( - participants: voiceChannel.getParticipantsList(), - selfUser: userSession.selfUser - ) - super.init( - contentViewController: visibleVoiceChannelViewController, - bottomSheetViewController: callingActionsInfoViewController, - bottomSheetConfiguration: .init(height: bottomSheetMaxHeight, initialOffset: 112.0) - ) - - callingActionsInfoViewController - .setCallingActionsViewDelegate(actionsDelegate: visibleVoiceChannelViewController) - callingActionsInfoViewController.actionsView.bottomSheetScrollingDelegate = self - callingActionsInfoViewController.delegate = self - - visibleVoiceChannelViewController.configurationObserver = self - self.participantsObserverToken = voiceChannel.addParticipantObserver(self) - visibleVoiceChannelViewController.delegate = self - - callDegradationController.targetViewController = self - view.insertSubview(overlay, belowSubview: bottomSheetViewController.view) - view.insertSubview(headerBar, belowSubview: overlay) - setupViews() - addConstraints() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - stopCallDurationTimer() - } - - override func viewDidLoad() { - super.viewDidLoad() - - // This is a hack for testing as a real ZMUserSession is not available - guard userSession is ZMUserSession else { - WireLogger.calling.error("UserSession not available when initializing \(type(of: self))") - return - } - callStateObserverToken = WireCallCenterV3.addCallStateObserver( - observer: self, - contextProvider: userSession.contextProvider - ) - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - visibleVoiceChannelViewController.reloadGrid() - } - - private func setupViews() { - view.backgroundColor = SemanticColors.View.backgroundDefault - headerBar.minimalizeButton.addTarget(self, action: #selector(hideCallView), for: .touchUpInside) - overlay.alpha = 0.0 - overlay.backgroundColor = SemanticColors.View.backgroundCallOverlay - addToSelf(callDegradationController) - callDegradationController.delegate = self - } - - private func addConstraints() { - headerBar.translatesAutoresizingMaskIntoConstraints = false - overlay.translatesAutoresizingMaskIntoConstraints = false - callDegradationController.view.fitIn(view: view) - - NSLayoutConstraint.activate([ - headerBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), - headerBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), - headerBar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), - headerBar.bottomAnchor.constraint(equalTo: visibleVoiceChannelViewController.view.topAnchor) - .withPriority(.required), - overlay.leadingAnchor.constraint(equalTo: view.leadingAnchor), - overlay.trailingAnchor.constraint(equalTo: view.trailingAnchor), - overlay.topAnchor.constraint(equalTo: view.topAnchor), - overlay.bottomAnchor.constraint(equalTo: view.bottomAnchor) - ]) - } - - // after rotating device recalculate bottom sheet max height - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - super.viewWillTransition(to: size, with: coordinator) - updateConstraints(forHeight: size.height) - } - - private func updateConstraints(forHeight height: CGFloat) { - let isLandscape = UIDevice.current.twoDimensionOrientation.isLandscape - // if landscape then bottom sheet should take whole screen (without headerBar) - let bottomSheetMaxHeight = isLandscape ? (height - headerBar.bounds.height) : bottomSheetMaxHeight - let newConfiguration = BottomSheetConfiguration( - height: bottomSheetMaxHeight, - initialOffset: bottomSheetMinimalOffset - ) - guard configuration != newConfiguration else { return } - configuration = newConfiguration - callingActionsInfoViewController.updateActionViewHeight() - callingActionsInfoViewController.actionsView.viewWillRotate(toPortrait: !isLandscape) - hideBottomSheet() - } - - override func didChangeState() { - switch state { - case .initial: - visibleVoiceChannelViewController.view.accessibilityElementsHidden = false - visibleVoiceChannelViewController.view.isUserInteractionEnabled = true - case .full: - visibleVoiceChannelViewController.view.accessibilityElementsHidden = true - visibleVoiceChannelViewController.view.isUserInteractionEnabled = false - } - } - - func transition(to toViewController: CallViewController, from fromViewController: CallViewController) { - guard toViewController != fromViewController else { return } - addChild(toViewController) - - transition( - from: fromViewController, - to: toViewController, - duration: 0.35, - options: .transitionCrossDissolve, - animations: nil, - completion: { _ in - self.addContentViewController(contentViewController: toViewController) - NSLayoutConstraint.activate( - [ - self.headerBar.bottomAnchor.constraint(equalTo: toViewController.view.topAnchor) - .withPriority(.required) - ] - ) - fromViewController.removeFromParent() - self.view.bringSubviewToFront(self.bottomSheetViewController.view) - } - ) - } - - func updateVisibleVoiceChannelViewController() { - guard - let conversation = (userSession as? ZMUserSession)?.priorityCallConversation, - visibleVoiceChannelViewController.conversation != conversation, - let voiceChannel = conversation.voiceChannel - else { - return - } - - self.voiceChannel = voiceChannel - visibleVoiceChannelViewController = CallViewController( - voiceChannel: voiceChannel, - selfUser: userSession.selfUser, - isOverlayEnabled: false, - userSession: userSession - ) - visibleVoiceChannelViewController.configurationObserver = self - visibleVoiceChannelViewController.delegate = self - callingActionsInfoViewController - .setCallingActionsViewDelegate(actionsDelegate: visibleVoiceChannelViewController) - callingActionsInfoViewController.participants = voiceChannel.getParticipantsList() - participantsObserverToken = voiceChannel.addParticipantObserver(self) - } - - override func bottomSheetChangedOffset(fullHeightPercentage: CGFloat) { - overlay.alpha = fullHeightPercentage * 0.7 - } - - private func updateState() { - switch callInfoConfiguration?.state { - case .established: startCallDurationTimer() - case .terminating: stopCallDurationTimer() - default: break - } - } - - private func startCallDurationTimer() { - stopCallDurationTimer() - callDurationTimer = .scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in - guard let configuration = self?.callInfoConfiguration, - case .established = configuration.state else { return } - self?.headerBar.updateConfiguration(configuration: configuration) - } - } - - private func stopCallDurationTimer() { - callDurationTimer?.invalidate() - callDurationTimer = nil - } -} - -extension CallingBottomSheetViewController: CallInfoConfigurationObserver { - func didUpdateConfiguration(configuration: CallInfoConfiguration) { - if configuration.state != callInfoConfiguration?.state { - if case .established = configuration.state { - headerBar.updateConfiguration(configuration: configuration) - } - visibleVoiceChannelViewController.reloadGrid() - callInfoConfiguration = configuration - updateState() - } else { - callInfoConfiguration = configuration - } - - callDegradationController.state = configuration.degradationState - callingActionsInfoViewController.didUpdateConfiguration(configuration: configuration) - panGesture.isEnabled = !configuration.state.isIncoming - updateConstraints(forHeight: view.bounds.height) - } -} - -extension CallingBottomSheetViewController: WireCallCenterCallParticipantObserver { - func callParticipantsDidChange(conversation: ZMConversation, participants: [CallParticipant]) { - callingActionsInfoViewController.participants = voiceChannel.getParticipantsList() - } -} - -extension CallingBottomSheetViewController: WireCallCenterCallStateObserver { - func callCenterDidChange( - callState: CallState, - conversation: ZMConversation, - caller: UserType, - timestamp: Date?, - previousCallState: CallState? - ) { - updateVisibleVoiceChannelViewController() - } -} - -extension CallingBottomSheetViewController: CallDegradationControllerDelegate { - func continueDegradedCall() { - visibleVoiceChannelViewController.callingActionsViewPerformAction(.continueDegradedCall) - } - - func cancelDegradedCall() { - visibleVoiceChannelViewController.callingActionsViewPerformAction(.terminateDegradedCall) - } -} - -extension CallingBottomSheetViewController: CallViewControllerDelegate { - func callViewControllerDidDisappear( - _ callController: CallViewController, - for conversation: ZMConversation? - ) { - delegate?.activeCallViewControllerDidDisappear(self, for: conversation) - } - - @objc - func hideCallView() { - delegate?.activeCallViewControllerDidDisappear(self, for: voiceChannel.conversation) - } -} - -extension CallingBottomSheetViewController: CallingActionsInfoViewControllerDelegate { - - func actionsViewHeightChanged(to height: CGFloat) { - updateMinimalOffset(height) - } - - private func updateMinimalOffset(_ offset: CGFloat) { - guard configuration.initialOffset != offset else { return } - configuration = BottomSheetConfiguration(height: configuration.height, initialOffset: offset) - hideBottomSheet() - } -} - -private extension VoiceChannel { - func getParticipantsList() -> CallParticipantsList { - let sortedParticipants = participants( - ofKind: .all, - activeSpeakersLimit: CallInfoConfiguration.maxActiveSpeakers - ) - - return sortedParticipants.map { - CallParticipantsListCellConfiguration.callParticipant( - user: HashBox(value: $0.user), - callParticipantState: $0.state, - activeSpeakerState: $0.activeSpeakerState - ) - } - } -} - -private final class PassThroughOpaqueView: UIView { - override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { - false - } -} diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallActionsView/CallActionsView.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallActionsView/CallActionsView.swift index 7f5e5c3bc7a..7db9d007926 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallActionsView/CallActionsView.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallActionsView/CallActionsView.swift @@ -43,14 +43,13 @@ final class CallActionsView: UIView { private let cameraButton = IconLabelButton.camera() private let cameraButtonDisabled = UIView() private let speakerButton = IconLabelButton.speaker() - private let flipCameraButton = IconLabelButton.flipCamera() private let firstBottomRowSpacer = UIView() private let endCallButton = IconButton.endCall() private let secondBottomRowSpacer = UIView() private let acceptCallButton = IconButton.acceptCall() private var allButtons: [UIButton] { - [microphoneButton, cameraButton, speakerButton, flipCameraButton, endCallButton, acceptCallButton] + [microphoneButton, cameraButton, speakerButton, endCallButton, acceptCallButton] } // MARK: - Setup @@ -83,7 +82,7 @@ final class CallActionsView: UIView { verticalStackView.alignment = .center verticalStackView.spacing = 37 addSubview(verticalStackView) - [microphoneButton, cameraButton, flipCameraButton, speakerButton].forEach(topStackView.addArrangedSubview) + [microphoneButton, cameraButton, speakerButton].forEach(topStackView.addArrangedSubview) [firstBottomRowSpacer, endCallButton, secondBottomRowSpacer, acceptCallButton] .forEach(bottomStackView.addArrangedSubview) [speakersAllSegmentedView, topStackView].forEach(verticalStackView.addArrangedSubview) @@ -108,7 +107,6 @@ final class CallActionsView: UIView { microphoneButton.accessibilityLabel = Voice.MuteButton.title cameraButton.accessibilityLabel = Voice.VideoButton.title speakerButton.accessibilityLabel = Voice.SpeakerButton.title - flipCameraButton.accessibilityLabel = Voice.FlipVideoButton.title acceptCallButton.accessibilityLabel = Voice.AcceptButton.title } @@ -202,10 +200,9 @@ final class CallActionsView: UIView { videoButtonDisabledTapRecognizer?.isEnabled = !input.canToggleMediaType cameraButton.isEnabled = input.canToggleMediaType cameraButton.isSelected = input.mediaState.isSendingVideo && input.permissions.canAcceptVideoCalls - flipCameraButton.isEnabled = input.mediaState.isSendingVideo && input.permissions.canAcceptVideoCalls speakerButton.isSelected = input.mediaState.isSpeakerEnabled speakerButton.isEnabled = canToggleSpeakerButton(input) - [microphoneButton, cameraButton, flipCameraButton, speakerButton].forEach { $0.appearance = input.appearance } + [microphoneButton, cameraButton, speakerButton].forEach { $0.appearance = input.appearance } alpha = input.callState.isTerminating ? 0.4 : 1 isUserInteractionEnabled = !input.callState.isTerminating updateToLayoutSize(layoutSize, animated: true) @@ -239,7 +236,6 @@ final class CallActionsView: UIView { case cameraButton: .toggleVideoState case videoButtonDisabledTapRecognizer: .alertVideoUnavailable case speakerButton: .toggleSpeakerState - case flipCameraButton: .flipCamera case endCallButton: .terminateCall case acceptCallButton: .acceptCall default: fatalError("Unexpected Button: \(button)") @@ -252,15 +248,12 @@ final class CallActionsView: UIView { typealias Label = L10n.Localizable.Call.Actions.Label microphoneButton.accessibilityLabel = input.isMuted ? Label.toggleMuteOff : Label.toggleMuteOn - flipCameraButton.accessibilityLabel = Label.flipCamera speakerButton.accessibilityLabel = input.mediaState.isSpeakerEnabled ? Label.toggleSpeakerOff : Label .toggleSpeakerOn acceptCallButton.accessibilityLabel = Label.acceptCall endCallButton.accessibilityLabel = input.callState.canAccept ? Label.rejectCall : Label.terminateCall cameraButtonDisabled.accessibilityLabel = Label.toggleVideoOn cameraButton.accessibilityLabel = input.mediaState.isSendingVideo ? Label.toggleVideoOff : Label.toggleVideoOn - flipCameraButton.accessibilityLabel = input.cameraType == .front ? Label.switchToBackCamera : Label - .switchToFrontCamera speakersAllSegmentedView .accessibilityIdentifier = diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallController/ActiveCallRouter.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallController/ActiveCallRouter.swift index 2a09208f9eb..589c5aa09bf 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallController/ActiveCallRouter.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallController/ActiveCallRouter.swift @@ -163,12 +163,10 @@ extension ActiveCallRouter: ActiveCallRouterProtocol { // first responder when the call overlay is interactively dismissed but canceled. UIResponder.currentFirst?.resignFirstResponder() var activeCallViewController: UIViewController! - let bottomSheetActiveCallViewController = CallingBottomSheetViewController( - voiceChannel: voiceChannel, - userSession: userSession - ) - bottomSheetActiveCallViewController.delegate = callController - activeCallViewController = bottomSheetActiveCallViewController + let viewModel = CallingContainerViewModel(voiceChannel: voiceChannel, userSession: userSession) + let containerVC = CallingContainerViewController(viewModel: viewModel) + containerVC.delegate = callController + activeCallViewController = containerVC let modalVC = ModalPresentationViewController( viewController: activeCallViewController, diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallGridView/CallGridViewController.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallGridView/CallGridViewController.swift index 02e9e8c7b07..00b68e1bedc 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallGridView/CallGridViewController.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallGridView/CallGridViewController.swift @@ -17,6 +17,7 @@ // import avs +import Combine import DifferenceKit import UIKit import WireCommonComponents @@ -64,6 +65,11 @@ final class CallGridViewController: UIViewController { private var viewCache = [AVSClient: OrientableView]() private var networkQualityObserverToken: Any? private var networkQuality: NetworkQuality + private var reactionsCancellable: AnyCancellable? + + var reactionWallViewModel: CallReactionWallViewModel? { + didSet { subscribeToReactions() } + } private let mediaManager: AVSMediaManagerInterface private let voiceChannel: VoiceChannel @@ -362,6 +368,11 @@ final class CallGridViewController: UIViewController { userSession: userSession ) } + + selfCallParticipantView?.onFlipCamera = { [weak self] in + guard let self else { return } + delegate?.callGridViewController(self, perform: .flipCamera) + } } private func updateFloatingView(with stream: Stream?) { @@ -603,6 +614,31 @@ extension CallGridViewController { } } +// MARK: - Reaction Badges + +extension CallGridViewController { + + private func subscribeToReactions() { + reactionsCancellable = reactionWallViewModel?.reactionsPublisher + .receive(on: RunLoop.main) + .sink { [weak self] reactions in + self?.updateReactionBadges(reactions) + } + } + + private func updateReactionBadges(_ reactions: [UUID: String]) { + for (client, view) in viewCache { + let stream = configuration.streams.first { $0.streamId == client } + ?? (configuration.floatingStream?.streamId == client ? configuration.floatingStream : nil) + guard + let userID = stream?.user?.remoteIdentifier, + let participantView = view as? BaseCallParticipantView + else { continue } + participantView.showReaction(reactions[userID]) + } + } +} + // MARK: - Extensions extension EditableUserType { diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallGridView/CallParticipantViews/BaseCallParticipantView.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallGridView/CallParticipantViews/BaseCallParticipantView.swift index d492c667ead..f2d1cd83ab1 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallGridView/CallParticipantViews/BaseCallParticipantView.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallGridView/CallParticipantViews/BaseCallParticipantView.swift @@ -67,6 +67,20 @@ class BaseCallParticipantView: OrientableView { var avatarView = UserImageView(size: .normal) private let userSession: UserSession + private let reactionBadgeLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 28) + label.textAlignment = .center + label.backgroundColor = UIColor.black.withAlphaComponent(0.45) + label.layer.cornerRadius = 22 + label.layer.masksToBounds = true + label.translatesAutoresizingMaskIntoConstraints = false + label.isHidden = true + label.alpha = 0 + label.accessibilityIdentifier = "reactionBadge" + return label + }() + private var borderLayer = CALayer() // MARK: - Private Properties @@ -153,9 +167,26 @@ class BaseCallParticipantView: OrientableView { accessibilityHint = isMaximized ? Calling.UserCellMinimize.hint : Calling.UserCellFullscreen.hint } + func showReaction(_ emoji: String?) { + if let emoji { + reactionBadgeLabel.text = emoji + reactionBadgeLabel.isHidden = false + UIView.animate(withDuration: 0.2) { + self.reactionBadgeLabel.alpha = 1 + } + } else { + UIView.animate(withDuration: 0.3) { + self.reactionBadgeLabel.alpha = 0 + } completion: { _ in + self.reactionBadgeLabel.isHidden = true + } + } + } + func setupViews() { addSubview(avatarView) addSubview(userDetailsView) + addSubview(reactionBadgeLabel) backgroundColor = .graphite avatarView.user = stream.user @@ -173,6 +204,13 @@ class BaseCallParticipantView: OrientableView { func createConstraints() { [avatarView, userDetailsView].forEach { $0.translatesAutoresizingMaskIntoConstraints = false } + NSLayoutConstraint.activate([ + reactionBadgeLabel.topAnchor.constraint(equalTo: topAnchor, constant: 8), + reactionBadgeLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), + reactionBadgeLabel.widthAnchor.constraint(equalToConstant: 44), + reactionBadgeLabel.heightAnchor.constraint(equalToConstant: 44) + ]) + detailsConstraints = UserDetailsConstraints( view: userDetailsView, superview: self, diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallGridView/CallParticipantViews/SelfCallParticipantView.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallGridView/CallParticipantViews/SelfCallParticipantView.swift index 9b9e92b08f6..cb8336aee15 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallGridView/CallParticipantViews/SelfCallParticipantView.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallGridView/CallParticipantViews/SelfCallParticipantView.swift @@ -17,14 +17,31 @@ // import avs +import AVFoundation import UIKit +import WireDesign import WireSyncEngine +private final class FlipCameraButton: UIButton { + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + bounds.insetBy(dx: -10, dy: -10).contains(point) + } +} + final class SelfCallParticipantView: BaseCallParticipantView { + var onFlipCamera: (() -> Void)? + weak var previewView: AVSVideoPreview? private weak var videoContainerView: AVSVideoContainerView? + private let flipCameraButton = FlipCameraButton(type: .system) + + private static var canFlipCamera: Bool { + let front = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) + let back = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) + return front != nil && back != nil + } override var stream: Stream { didSet { @@ -51,6 +68,22 @@ final class SelfCallParticipantView: BaseCallParticipantView { scalableView.addSubview(videoContainerView) insertSubview(scalableView, belowSubview: userDetailsView) self.scalableView = scalableView + + setupFlipCameraButton() + } + + private func setupFlipCameraButton() { + let config = UIImage.SymbolConfiguration(pointSize: 12, weight: .medium) + flipCameraButton.setImage(UIImage(systemName: "camera.rotate.fill", withConfiguration: config), for: .normal) + flipCameraButton.tintColor = ColorTheme.Backgrounds.inverted + flipCameraButton.backgroundColor = ColorTheme.Backgrounds.onInverted + flipCameraButton.layer.cornerRadius = 16 + flipCameraButton.clipsToBounds = true + flipCameraButton.translatesAutoresizingMaskIntoConstraints = false + flipCameraButton.accessibilityLabel = L10n.Localizable.Call.Actions.Label.flipCamera + flipCameraButton.addTarget(self, action: #selector(flipCameraButtonTapped), for: .touchUpInside) + addSubview(flipCameraButton) + updateFlipCameraButtonVisibility() } override func createConstraints() { @@ -63,6 +96,28 @@ final class SelfCallParticipantView: BaseCallParticipantView { $0?.translatesAutoresizingMaskIntoConstraints = false $0?.fitIn(view: self) } + + NSLayoutConstraint.activate([ + flipCameraButton.topAnchor.constraint(equalTo: topAnchor, constant: 8), + flipCameraButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), + flipCameraButton.widthAnchor.constraint(equalToConstant: 32), + flipCameraButton.heightAnchor.constraint(equalToConstant: 32) + ]) + } + + @objc private func flipCameraButtonTapped() { + UIView.transition( + with: self, + duration: 0.5, + options: [.transitionFlipFromRight, .curveEaseInOut], + animations: nil, + completion: nil + ) + onFlipCamera?() + } + + private func updateFlipCameraButtonVisibility() { + flipCameraButton.isHidden = !(videoState == .started && Self.canFlipCamera) } override func updateUserDetails() { @@ -93,6 +148,7 @@ final class SelfCallParticipantView: BaseCallParticipantView { stopCapture() } videoState = newVideoState + updateFlipCameraButtonVisibility() } func startCapture() { diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallInfoViewController/CallAction.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallInfoViewController/CallAction.swift index a0e584c6569..c77a294bd1f 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallInfoViewController/CallAction.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallInfoViewController/CallAction.swift @@ -33,4 +33,7 @@ enum CallAction { case minimizeOverlay case showParticipantsList case updateVideoGridPresentationMode(_ mode: VideoGridPresentationMode) + case toggleReactionsTray + case sendReaction(emoji: String) + case openEmojiPicker } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallInfoViewController/CallGridAction.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallInfoViewController/CallGridAction.swift index 9c8a88631ad..c05f2917bfb 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallInfoViewController/CallGridAction.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallInfoViewController/CallGridAction.swift @@ -21,4 +21,5 @@ import WireSyncEngine // The actions `CallGridViewController` can perform. enum CallGridAction { case requestVideoStreamsForClients(_ clients: [AVSClientVideoStream]) + case flipCamera } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallViewController/CallGridConfiguration.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallViewController/CallGridConfiguration.swift index 6fafd2db61b..603b6e713d2 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallViewController/CallGridConfiguration.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallViewController/CallGridConfiguration.swift @@ -79,17 +79,24 @@ extension VoiceChannel { func arrangeStreams(for selfStream: Stream?, participantsStreams: [Stream]) -> StreamArrangment { let streamsExcludingSelf = participantsStreams.filter { $0.streamId != selfStreamId } - let sortedStreamsList = sortByVideo(streamData: streamsExcludingSelf) + let sortedStreamsList = sortByPriority(streamData: streamsExcludingSelf) guard let selfStream else { return (nil, sortedStreamsList) } if callHasTwoParticipants, sortedStreamsList.count == 1 { return (selfStream, sortedStreamsList) } else { - return (nil, [selfStream] + sortedStreamsList) + let highPriority = sortedStreamsList.filter { + $0.videoState == .screenSharing || $0.activeSpeakerState != .inactive + } + let rest = sortedStreamsList.filter { + $0.videoState != .screenSharing && $0.activeSpeakerState == .inactive + } + return (nil, highPriority + [selfStream] + rest) } } + // TODO: Remove once sortByPriority is validated in production func sortByVideo(streamData: [Stream]) -> [Stream] { streamData.sorted { guard let videoStatusArgument0 = $0.videoState?.isSending else { return false } @@ -98,6 +105,16 @@ extension VoiceChannel { } } + func sortByPriority(streamData: [Stream]) -> [Stream] { + streamData.sorted { streamPriority($0) < streamPriority($1) } + } + + private func streamPriority(_ stream: Stream) -> Int { + if stream.videoState == .screenSharing { return 0 } + if stream.activeSpeakerState != .inactive { return 1 } + return 2 + } + private var streamArrangementForNonEstablishedCall: StreamArrangment { guard videoGridPresentationMode.needsSelfStream, let stream = createSelfStream() else { return (nil, []) diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallViewController/CallViewController.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallViewController/CallViewController.swift index 9470832c6cd..a59aad45cf6 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallViewController/CallViewController.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Calling/CallViewController/CallViewController.swift @@ -17,10 +17,14 @@ // import avs +import Combine +import SwiftUI import UIKit import WireCommonComponents +import WireDomain import WireLogging import WireSyncEngine +import WireUtilities protocol CallViewControllerDelegate: AnyObject { func callViewControllerDidDisappear( @@ -29,6 +33,10 @@ protocol CallViewControllerDelegate: AnyObject { ) } +protocol CallInfoConfigurationObserver: AnyObject { + func didUpdateConfiguration(configuration: CallInfoConfiguration) +} + final class CallViewController: UIViewController { weak var delegate: CallViewControllerDelegate? @@ -55,6 +63,7 @@ final class CallViewController: UIViewController { private var doubleTapRecognizer: UITapGestureRecognizer! private var isInteractiveDismissal = false + private var callReactionWallViewModel: CallReactionWallViewModel? let userSession: UserSession @@ -72,7 +81,11 @@ final class CallViewController: UIViewController { Settings.shared[.callingConstantBitRate] == true } - weak var configurationObserver: CallInfoConfigurationObserver? + weak var configurationObserver: CallInfoConfigurationObserver? { + didSet { + configurationObserver?.didUpdateConfiguration(configuration: callInfoConfiguration) + } + } init( voiceChannel: VoiceChannel, @@ -256,6 +269,46 @@ final class CallViewController: UIViewController { } view.backgroundColor = .clear + if DeveloperFlag.inCallReactions.isOn { + setupCallReactionWall() + } + } + + private func setupCallReactionWall() { + guard let conversationID = conversation?.remoteIdentifier else { return } + + let publisher = userSession.clientSessionComponent?.callReactionSubject + .eraseToAnyPublisher() + ?? Empty().eraseToAnyPublisher() + + let selfUser = userSession.selfUser + let viewModel = CallReactionWallViewModel( + publisher: publisher, + conversationID: conversationID, + sendUseCase: userSession.makeSendCallReactionUseCase(), + conversation: conversation, + selfSenderID: selfUser.remoteIdentifier ?? UUID(), + selfSenderName: selfUser.name ?? "" + ) + callReactionWallViewModel = viewModel + callGridViewController.reactionWallViewModel = viewModel + + let wall = CallReactionWall(viewModel: viewModel) + let hostingController = UIHostingController(rootView: wall) + hostingController.view.backgroundColor = .clear + hostingController.view.isOpaque = false + hostingController.view.isUserInteractionEnabled = false + + addChild(hostingController) + view.insertSubview(hostingController.view, aboveSubview: callGridViewController.view) + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + hostingController.view.topAnchor.constraint(equalTo: callGridViewController.view.topAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: callGridViewController.view.bottomAnchor), + hostingController.view.leadingAnchor.constraint(equalTo: callGridViewController.view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: callGridViewController.view.trailingAnchor) + ]) + hostingController.didMove(toParent: self) } private func createConstraints() { @@ -579,6 +632,13 @@ extension CallViewController: CallInfoRootViewControllerDelegate { case .flipCamera: toggleCameraAnimated() case .showParticipantsList: return // Handled in `CallInfoRootViewController`, we don't want to update. case let .updateVideoGridPresentationMode(mode): voiceChannel.videoGridPresentationMode = mode + case .toggleReactionsTray: + WireLogger.calling.debug("😄 request to toggle reactions tray") + break + case .sendReaction(let emoji): + callReactionWallViewModel?.sendReaction(emoji: emoji) + case .openEmojiPicker: + presentEmojiPicker() } updateConfiguration() @@ -602,6 +662,7 @@ extension CallViewController: CallGridViewControllerDelegate { func callGridViewController(_ viewController: CallGridViewController, perform action: CallGridAction) { switch action { case let .requestVideoStreamsForClients(clients): voiceChannel.request(videoStreams: clients) + case .flipCamera: toggleCameraAnimated() } } } @@ -700,6 +761,29 @@ extension CallViewController { } +// MARK: - Emoji picker + +extension CallViewController: EmojiPickerViewControllerDelegate { + + func presentEmojiPicker() { + let picker = EmojiKeyboardViewController() + picker.delegate = self + picker.modalPresentationStyle = .pageSheet + if let sheet = picker.sheetPresentationController { + sheet.detents = [.medium()] + sheet.prefersGrabberVisible = false + } + present(picker, animated: true) + } + + func emojiPickerDidSelectEmoji(_ emoji: Emoji) { + callReactionWallViewModel?.sendReaction(emoji: emoji.value) + dismiss(animated: true) + } + + func emojiPickerDeleteTapped() {} +} + extension CallViewController { func proximityStateDidChange(_ raisedToEar: Bool) {