Redesigned CallDrawerSheet member cells

This commit is contained in:
Sasha Weiss 2026-03-19 12:06:14 -07:00 committed by GitHub
parent ebc5d18187
commit 9d17d346f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 544 additions and 362 deletions

View File

@ -2727,7 +2727,6 @@
D93964B62E038C7B00094117 /* BackupAttachmentUploadTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93964B52E038C7B00094117 /* BackupAttachmentUploadTracker.swift */; };
D93BDD942E43064500779BD8 /* BackupKeepKeySafeSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93BDD932E43063B00779BD8 /* BackupKeepKeySafeSheet.swift */; };
D93CE1242A5C84F600D916B7 /* OutgoingRequestSyncMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93CE1232A5C84F600D916B7 /* OutgoingRequestSyncMessage.swift */; };
D93E307E2F5A96DE00CCF7C0 /* GroupCallContextMenuActionsBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93E307D2F5A96D200CCF7C0 /* GroupCallContextMenuActionsBuilder.swift */; };
D93EDC042AE9E3CD0004BDD9 /* DonationSettingsViewController+MySupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93EDC032AE9E3CD0004BDD9 /* DonationSettingsViewController+MySupport.swift */; };
D93F4D5A2D800DD20042926C /* AvatarDefaultColorManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93F4D552D7FAC3C0042926C /* AvatarDefaultColorManager.swift */; };
D93F4D5D2D801D750042926C /* AvatarDefaultColorManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93F4D5C2D801D700042926C /* AvatarDefaultColorManagerTest.swift */; };
@ -7000,7 +6999,6 @@
D93964B52E038C7B00094117 /* BackupAttachmentUploadTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupAttachmentUploadTracker.swift; sourceTree = "<group>"; };
D93BDD932E43063B00779BD8 /* BackupKeepKeySafeSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupKeepKeySafeSheet.swift; sourceTree = "<group>"; };
D93CE1232A5C84F600D916B7 /* OutgoingRequestSyncMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutgoingRequestSyncMessage.swift; sourceTree = "<group>"; };
D93E307D2F5A96D200CCF7C0 /* GroupCallContextMenuActionsBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupCallContextMenuActionsBuilder.swift; sourceTree = "<group>"; };
D93EDC032AE9E3CD0004BDD9 /* DonationSettingsViewController+MySupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DonationSettingsViewController+MySupport.swift"; sourceTree = "<group>"; };
D93F4D552D7FAC3C0042926C /* AvatarDefaultColorManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarDefaultColorManager.swift; sourceTree = "<group>"; };
D93F4D5C2D801D700042926C /* AvatarDefaultColorManagerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarDefaultColorManagerTest.swift; sourceTree = "<group>"; };
@ -14036,7 +14034,6 @@
D9E43BAB2CC194140001536E /* CreateCallLinkViewController.swift */,
B9A53B982D0250FC0000578B /* EditCallLinkNameViewController.swift */,
D9E43BAD2CC194140001536E /* FlipCameraTooltip.swift */,
D93E307D2F5A96D200CCF7C0 /* GroupCallContextMenuActionsBuilder.swift */,
D9E43BAE2CC194140001536E /* GroupCallErrorView.swift */,
D9E43BAF2CC194140001536E /* GroupCallNotificationView.swift */,
D9E43BB02CC194140001536E /* GroupCallSwipeToastView.swift */,
@ -18430,7 +18427,6 @@
34EA0A002423C7F80059B75F /* GroupAttributesViewController.swift in Sources */,
D9E43C252CC194140001536E /* GroupCall.swift in Sources */,
D9E43C262CC194140001536E /* GroupCallAccessoryMessageDelegate.swift in Sources */,
D93E307E2F5A96DE00CCF7C0 /* GroupCallContextMenuActionsBuilder.swift in Sources */,
D9E43BFD2CC194140001536E /* GroupCallErrorView.swift in Sources */,
D9E43BFE2CC194140001536E /* GroupCallNotificationView.swift in Sources */,
D9E43C272CC194140001536E /* GroupCallRecordRingingCleanupManager.swift in Sources */,

View File

@ -30,6 +30,10 @@ final class CallLinkCall: Signal.GroupCall {
)
}
var isAdmin: Bool {
adminPasskey != nil
}
var mayNeedToAskToJoin: Bool {
return callLinkState.requiresAdminApproval && adminPasskey == nil
}

View File

@ -16,7 +16,7 @@ protocol CallDrawerDelegate: AnyObject {
// MARK: - GroupCallSheet
class CallDrawerSheet: InteractiveSheetViewController {
class CallDrawerSheet: InteractiveSheetViewController, UITableViewDelegate, GroupCallMemberCellDelegate, CallDrawerSheetDataSourceObserver, EmojiPickerSheetPresenter, CallControlsHeightObserver {
private let callControls: CallControls
// MARK: Properties
@ -206,22 +206,9 @@ class CallDrawerSheet: InteractiveSheetViewController {
return cell
}
let isCallAdmin = self?.callLinkDataSource?.isAdmin ?? false
let canBeRemoved = section == .inCall && !viewModel.isLocalUser && isCallAdmin
let removeUserButtonVisibility: GroupCallMemberCell.Visibility =
if canBeRemoved {
.visible
} else if isCallAdmin, section == .inCall {
.spaceReserved
} else {
.hidden
}
cell.configure(
with: viewModel,
isHandRaised: section == .raisedHands,
removeUserButtonVisibility: removeUserButtonVisibility,
)
return cell
@ -350,15 +337,16 @@ class CallDrawerSheet: InteractiveSheetViewController {
struct JoinedMember {
enum ID: Hashable {
case serviceId(ServiceId)
case aci(Aci)
case demuxID(DemuxId)
}
let id: ID
let serviceId: ServiceId
let aci: Aci
let displayName: String
let comparableName: DisplayName.ComparableValue
let avatarImage: UIImage?
let demuxID: DemuxId?
let isLocalUser: Bool
let isUnknown: Bool
@ -394,7 +382,7 @@ class CallDrawerSheet: InteractiveSheetViewController {
if let existingViewModel = partialResult[member.id] {
existingViewModel.update(using: member)
} else {
partialResult[member.id] = .init(member: member)
partialResult[member.id] = GroupCallMemberCell.ViewModel(member: member)
}
}
}
@ -425,8 +413,8 @@ class CallDrawerSheet: InteractiveSheetViewController {
if let nameComparison {
return nameComparison
}
if $0.serviceId != $1.serviceId {
return $0.serviceId < $1.serviceId
if $0.aci != $1.aci {
return $0.aci < $1.aci
}
return $0.demuxID ?? 0 < $1.demuxID ?? 0
}
@ -642,11 +630,9 @@ class CallDrawerSheet: InteractiveSheetViewController {
// The call drawer always uses dark styling regardless of the
// system setting, so ignore.
}
}
// MARK: UITableViewDelegate
// MARK: - UITableViewDelegate
extension CallDrawerSheet: UITableViewDelegate {
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let section = dataSource.snapshot().sectionIdentifiers[section]
switch section {
@ -733,88 +719,78 @@ extension CallDrawerSheet: UITableViewDelegate {
guard
let viewModel = viewModelsByID[memberId],
!viewModel.isLocalUser,
let demuxId = viewModel.demuxId,
let contactAci = viewModel.serviceId as? Aci
let demuxId = viewModel.demuxId
else {
return nil
}
let ringRtcGroupCall: SignalRingRTC.GroupCall
let groupCall: GroupCall
switch call.mode {
case .individual:
return nil
case .groupThread(let groupThreadCall):
ringRtcGroupCall = groupThreadCall.ringRtcCall
groupCall = groupThreadCall
case .callLink(let callLinkCall):
ringRtcGroupCall = callLinkCall.ringRtcCall
groupCall = callLinkCall
}
let actions = GroupCallContextMenuActionsBuilder.build(
return GroupCallVideoContextMenuConfiguration.build(
call: call,
groupCall: groupCall,
ringRtcCall: groupCall.ringRtcCall,
demuxId: demuxId,
contactAci: contactAci,
aci: viewModel.aci,
isAudioMuted: viewModel.isAudioMuted,
ringRtcGroupCall: ringRtcGroupCall,
)
return UIContextMenuConfiguration(
actionProvider: { _ in
return UIMenu(title: viewModel.name, children: actions)
interactionProvider: { [weak tableView] in
return tableView?.interactions
.compactMap { $0 as? UIContextMenuInteraction }
.first
},
)
}
}
}
// MARK: GroupCallMemberCellDelegate
// MARK: - GroupCallMemberCellDelegate
extension CallDrawerSheet: GroupCallMemberCellDelegate {
func raiseHand(raise: Bool) {
callSheetDataSource.raiseHand(raise: raise)
}
func removeMember(demuxId: DemuxId) {
guard let callLinkDataSource else {
return owsFailDebug("Missing call link data source")
}
guard let name = viewModelsByID[.demuxID(demuxId)]?.name else {
return owsFailDebug("Missing view model for demux ID")
fileprivate func overflowButtonContextMenuActions(demuxId: DemuxId, aci: Aci, displayName: String, isAudioMuted: Bool) -> [UIAction] {
let groupCall: Signal.GroupCall
switch call.mode {
case .individual:
owsFailDebug("Individual call with demux ID?")
return []
case .groupThread(let groupThreadCall):
groupCall = groupThreadCall
case .callLink(let callLinkCall):
groupCall = callLinkCall
}
let actionSheet = ActionSheetController(
title: String(
format: OWSLocalizedString(
"GROUP_CALL_REMOVE_MEMBER_CONFIRMATION_ACTION_SHEET_TITLE",
comment: "Title for action sheet confirming removal of a member from a group call. embeds {{ name }}",
),
name,
),
return GroupCallVideoContextMenuConfiguration.contextMenuActions(
demuxId: demuxId,
aci: aci,
displayName: displayName,
isAudioMuted: isAudioMuted,
groupCall: groupCall,
ringRtcGroupCall: groupCall.ringRtcCall,
)
actionSheet.overrideUserInterfaceStyle = .dark
actionSheet.addAction(.init(
title: OWSLocalizedString(
"GROUP_CALL_REMOVE_MEMBER_CONFIRMATION_ACTION_SHEET_REMOVE_ACTION",
comment: "Label for the button to confirm removing a member from a group call.",
),
) { [callLinkDataSource] _ in
callLinkDataSource.removeMember(demuxId: demuxId)
})
actionSheet.addAction(.init(
title: OWSLocalizedString(
"GROUP_CALL_REMOVE_MEMBER_CONFIRMATION_ACTION_SHEET_BLOCK_ACTION",
comment: "Label for a button to block a member from a group call.",
),
) { [callLinkDataSource] _ in
callLinkDataSource.blockMember(demuxId: demuxId)
})
actionSheet.addAction(.cancel)
self.presentActionSheet(actionSheet)
}
}
// MARK: CallObserver
fileprivate func raiseHand(raise: Bool) {
let groupCall: Signal.GroupCall
switch call.mode {
case .individual:
owsFailDebug("Raising hand in 1:1 call?")
return
case .groupThread(let groupThreadCall):
groupCall = groupThreadCall
case .callLink(let callLinkCall):
groupCall = callLinkCall
}
groupCall.ringRtcCall.raiseHand(raise: raise)
}
// MARK: - CallDrawerSheetDataSourceObserver
extension CallDrawerSheet: CallDrawerSheetDataSourceObserver {
func callSheetMembershipDidChange(_ dataSource: CallDrawerSheetDataSource) {
AssertIsOnMainThread()
updateMembers()
@ -824,15 +800,15 @@ extension CallDrawerSheet: CallDrawerSheetDataSourceObserver {
AssertIsOnMainThread()
updateSnapshotAndHeaders()
}
}
extension CallDrawerSheet: EmojiPickerSheetPresenter {
// MARK: - EmojiPickerSheetPresenter
func present(sheet: EmojiPickerSheet, animated: Bool) {
self.present(sheet, animated: animated)
}
}
extension CallDrawerSheet {
// MARK: -
func isPresentingCallControls() -> Bool {
return self.presentingViewController != nil && callControls.alpha == 1
}
@ -844,6 +820,33 @@ extension CallDrawerSheet {
func isCrossFading() -> Bool {
return self.presentingViewController != nil && callControls.alpha < 1 && tableView.alpha < 1
}
// MARK: - CallControlsHeightObserver
func callControlsHeightDidChange(newHeight: CGFloat) {
self.cancelAnimationAndUpdateConstraints()
self.animate {
self.setBottomSheetMinimizedHeight()
self.view.layoutIfNeeded()
}
}
override open func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
self.setBottomSheetMinimizedHeight()
}
private var bottomPadding: CGFloat {
max(self.view.safeAreaInsets.bottom + HeightConstants.bottomPadding, HeightConstants.minimumBottomPaddingIncludingSafeArea)
}
private enum HeightConstants {
static let bottomPadding: CGFloat = 14
static let minimumBottomPaddingIncludingSafeArea: CGFloat = 30
static let initialTableInset: CGFloat = 25
static let titleViewBottomPadding: CGFloat = 16
static let tableViewTopPadding: CGFloat = 8
}
}
// MARK: - CallLinkURLCell
@ -892,9 +895,9 @@ private class CallLinkURLCell: UITableViewCell, ReusableTableViewCell {
// MARK: - GroupCallMemberCell
protocol GroupCallMemberCellDelegate: AnyObject {
private protocol GroupCallMemberCellDelegate: AnyObject {
func overflowButtonContextMenuActions(demuxId: DemuxId, aci: Aci, displayName: String, isAudioMuted: Bool) -> [UIAction]
func raiseHand(raise: Bool)
func removeMember(demuxId: DemuxId)
}
private class GroupCallMemberCell: UITableViewCell, ReusableTableViewCell {
@ -904,27 +907,27 @@ private class GroupCallMemberCell: UITableViewCell, ReusableTableViewCell {
class ViewModel {
typealias Member = CallDrawerSheet.JoinedMember
let serviceId: ServiceId
let aci: Aci
let name: String
let avatarImage: UIImage?
let isLocalUser: Bool
let demuxId: DemuxId?
@Published var isAudioMuted = false
@Published var isVideoMuted = false
@Published var isPresenting = false
init(member: Member) {
self.serviceId = member.serviceId
self.aci = member.aci
self.name = member.displayName
self.avatarImage = member.avatarImage
self.isLocalUser = member.isLocalUser
self.demuxId = member.demuxID
self.update(using: member)
}
func update(using member: Member) {
owsAssertDebug(serviceId == member.serviceId)
owsAssertDebug(aci == member.aci)
self.isAudioMuted = member.isAudioMuted ?? false
self.isVideoMuted = member.isVideoMuted == true && member.isPresenting != true
self.isPresenting = member.isPresenting ?? false
}
}
@ -933,102 +936,62 @@ private class GroupCallMemberCell: UITableViewCell, ReusableTableViewCell {
static let reuseIdentifier = "GroupCallMemberCell"
weak var delegate: GroupCallMemberCellDelegate?
private let avatarView = ConversationAvatarView(
sizeClass: .thirtySix,
localUserDisplayMode: .asUser,
badged: false,
)
private let nameLabel = UILabel()
private lazy var lowerHandButton = OWSButton(
title: CallStrings.lowerHandButton,
tintColor: .ows_white,
dimsWhenHighlighted: true,
) { [weak self] in
self?.delegate?.raiseHand(raise: false)
}
private var demuxId: DemuxId?
private lazy var removeUserButton: OWSButton = {
let button = OWSButton { [weak self] in
guard let self, let demuxId else { return }
self.delegate?.removeMember(demuxId: demuxId)
}
button.setAttributedTitle(
SignalSymbol.minusCircle.attributedString(
dynamicTypeBaseSize: 24,
weight: .light,
attributes: [.foregroundColor: UIColor.Signal.label],
),
for: .normal,
)
button.dimsWhenHighlighted = true
private lazy var lowerHandButton: UIButton = {
let button = UIButton(primaryAction: UIAction(handler: { [weak self] _ in
self?.delegate?.raiseHand(raise: false)
}))
var config = UIButton.Configuration.plain()
config.title = CallStrings.lowerHandButton
config.titleTextAttributesTransformer = .defaultFont(.dynamicTypeBody)
config.baseForegroundColor = .ows_white
config.contentInsets.leading = 0
config.contentInsets.trailing = 0
button.configuration = config
return button
}()
private let leadingWrapper = UIView()
private let videoMutedIndicator = UIImageView()
private let presentingIndicator = UIImageView()
private let raisedHandIndicator: UIImageView = {
let imageView = UIImageView(image: .raiseHand)
imageView.tintColor = .Signal.secondaryLabel
return imageView
}()
private let audioMutedIndicator = UIImageView()
private let raisedHandIndicator = UIImageView()
private lazy var audioMutedIndicator: UIImageView = {
let imageView = UIImageView(image: .micSlash)
imageView.tintColor = .Signal.secondaryLabel
return imageView
}()
private var subscriptions = Set<AnyCancellable>()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
nameLabel.textColor = Theme.darkThemePrimaryColor
nameLabel.font = .dynamicTypeBody
lowerHandButton.titleLabel?.font = .dynamicTypeBody
func setup(iconView: UIImageView, withImageNamed imageName: String, in wrapper: UIView) {
iconView.setTemplateImageName(imageName, tintColor: Theme.darkThemeSecondaryTextAndIconColor)
wrapper.addSubview(iconView)
iconView.autoPinEdgesToSuperviewEdges()
iconView.setCompressionResistanceHorizontalHigh()
iconView.setContentHuggingHorizontalHigh()
}
let trailingWrapper = UIView()
setup(iconView: audioMutedIndicator, withImageNamed: "mic-slash", in: trailingWrapper)
setup(iconView: raisedHandIndicator, withImageNamed: Theme.iconName(.raiseHand), in: trailingWrapper)
setup(iconView: videoMutedIndicator, withImageNamed: "video-slash", in: leadingWrapper)
setup(iconView: presentingIndicator, withImageNamed: "share_screen", in: leadingWrapper)
private lazy var overflowButton: ContextMenuButton = {
let button = ContextMenuButton(empty: ())
var config = UIButton.Configuration.plain()
config.image = .more
config.baseForegroundColor = .Signal.secondaryLabel
button.configuration = config
button.autoSetDimensions(to: .square(24))
return button
}()
private lazy var accessoryStack: UIStackView = {
let stackView = UIStackView(arrangedSubviews: [
avatarView,
nameLabel,
lowerHandButton,
leadingWrapper,
trailingWrapper,
removeUserButton,
raisedHandIndicator,
audioMutedIndicator,
overflowButton,
])
stackView.axis = .horizontal
stackView.alignment = .center
contentView.addSubview(stackView)
stackView.autoPinWidthToSuperviewMargins()
stackView.autoPinHeightToSuperview(withMargin: 7)
stackView.spacing = 16
stackView.setCustomSpacing(12, after: avatarView)
stackView.setCustomSpacing(8, after: nameLabel)
return stackView
}()
nameLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
nameLabel.setContentHuggingHorizontalLow()
nameLabel.setCompressionResistanceHorizontalLow()
[leadingWrapper, trailingWrapper, removeUserButton, lowerHandButton]
.forEach {
$0.setContentHuggingHorizontalHigh()
$0.setCompressionResistanceHorizontalHigh()
}
private var subscriptions = Set<AnyCancellable>()
weak var delegate: GroupCallMemberCellDelegate?
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
}
required init?(coder aDecoder: NSCoder) {
@ -1037,67 +1000,118 @@ private class GroupCallMemberCell: UITableViewCell, ReusableTableViewCell {
// MARK: Configuration
enum Visibility {
case visible
case spaceReserved
case hidden
}
// isHandRaised isn't part of ViewModel because the same view model is used
// for any given member in both the members and raised hand sections.
//
// previewAvatarColor is a hack to support Xcode Previews, which can't build
// a contact avatar without globals set up.
func configure(
with viewModel: ViewModel,
isHandRaised: Bool,
removeUserButtonVisibility: Visibility,
) {
self.subscriptions.removeAll()
var config = defaultContentConfiguration()
defer {
self.contentConfiguration = config
}
config.directionalLayoutMargins = NSDirectionalEdgeInsets(hMargin: 16, vMargin: 7)
config.image = viewModel.avatarImage
config.imageProperties.tintColor = nil
config.imageProperties.reservedLayoutSize = .square(36)
config.imageProperties.maximumSize = .square(36)
config.imageProperties.cornerRadius = 18
config.text = viewModel.name
config.textProperties.color = .Signal.label
config.textProperties.font = .dynamicTypeBody
let isPresentingIconText = SignalSymbol.shareScreenFill.attributedString(dynamicTypeBaseSize: UIFont.dynamicTypeBody.pointSize)
let isPresentingAttributedText = isPresentingIconText + " " + OWSLocalizedString(
"GROUP_CALL_MEMBER_LIST_PRESENTING_SUBTITLE",
comment: "Subtitle for a row representing a call member, when that member is presenting.",
)
config.secondaryAttributedText = viewModel.isPresenting ? isPresentingAttributedText : nil
config.secondaryTextProperties.color = .Signal.secondaryLabel
config.secondaryTextProperties.font = .dynamicTypeBody
self.subscribe(to: viewModel.$isPresenting) { [weak self] isPresenting in
guard
let self,
var config = self.contentConfiguration as? UIListContentConfiguration
else { return }
config.secondaryAttributedText = isPresenting ? isPresentingAttributedText : nil
self.contentConfiguration = config
}
if isHandRaised {
self.raisedHandIndicator.isHidden = false
self.lowerHandButton.isHiddenInStackView = !viewModel.isLocalUser
self.lowerHandButton.isHidden = !viewModel.isLocalUser
self.audioMutedIndicator.isHidden = true
self.leadingWrapper.isHiddenInStackView = true
self.overflowButton.isHidden = true
} else {
self.raisedHandIndicator.isHidden = true
self.lowerHandButton.isHiddenInStackView = true
self.leadingWrapper.isHiddenInStackView = false
self.subscribe(to: viewModel.$isAudioMuted, showing: self.audioMutedIndicator)
self.subscribe(to: viewModel.$isVideoMuted, showing: self.videoMutedIndicator)
self.subscribe(to: viewModel.$isPresenting, showing: self.presentingIndicator)
self.lowerHandButton.isHidden = true
configureAudioAndOverflowButtons(
demuxId: viewModel.demuxId,
aci: viewModel.aci,
displayName: viewModel.name,
isAudioMuted: viewModel.isAudioMuted,
)
self.subscribe(to: viewModel.$isAudioMuted) { [weak self] isMuted in
self?.configureAudioAndOverflowButtons(
demuxId: viewModel.demuxId,
aci: viewModel.aci,
displayName: viewModel.name,
isAudioMuted: isMuted,
)
}
}
self.nameLabel.text = viewModel.name
self.avatarView.updateWithSneakyTransactionIfNecessary { config in
config.dataSource = .address(SignalServiceAddress(viewModel.serviceId))
}
self.accessoryStack.sizeToFit()
self.accessoryStack.frame = CGRect(
origin: .zero,
size: accessoryStack.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize),
)
self.accessoryView = accessoryStack
}
self.demuxId = viewModel.demuxId
switch removeUserButtonVisibility {
case .visible:
self.removeUserButton.isHiddenInStackView = false
self.removeUserButton.layer.opacity = 1
case .spaceReserved:
self.removeUserButton.isHiddenInStackView = false
self.removeUserButton.layer.opacity = 0
case .hidden:
self.removeUserButton.isHiddenInStackView = true
private func configureAudioAndOverflowButtons(
demuxId: DemuxId?,
aci: Aci,
displayName: String,
isAudioMuted: Bool,
) {
// Always reserve space for mute icon, but conditionally show it.
self.audioMutedIndicator.isHidden = false
self.audioMutedIndicator.alpha = isAudioMuted ? 1 : 0
if let demuxId {
let actions = delegate?.overflowButtonContextMenuActions(
demuxId: demuxId,
aci: aci,
displayName: displayName,
isAudioMuted: isAudioMuted,
) ?? []
self.overflowButton.isHidden = false
self.overflowButton.setActions(actions: actions)
} else {
self.overflowButton.isHidden = true
}
}
func hideContent() {
self.raisedHandIndicator.isHidden = true
self.lowerHandButton.isHiddenInStackView = true
self.audioMutedIndicator.isHidden = true
self.leadingWrapper.isHiddenInStackView = true
self.removeUserButton.isHiddenInStackView = true
self.contentConfiguration = nil
self.accessoryView = nil
}
private func subscribe(to publisher: Published<Bool>.Publisher, showing view: UIView) {
private func subscribe(to publisher: Published<Bool>.Publisher, onUpdate: @escaping (Bool) -> Void) {
publisher
.removeDuplicates()
.sink { [weak view] shouldShow in
view?.isHidden = !shouldShow
}
.sink { onUpdate($0) }
.store(in: &self.subscriptions)
}
}
@ -1275,53 +1289,97 @@ private class UnknownMembersCell: UITableViewCell, ReusableTableViewCell {
}
frontAvatar.configure(
with: unknownMembers.members.first?.serviceId,
with: unknownMembers.members.first?.aci,
totalAvatars: unknownMembers.members.count,
)
if unknownMembers.members.count == 2 {
middleAvatar.hide()
backAvatar.configure(
with: unknownMembers.members.last?.serviceId,
with: unknownMembers.members.last?.aci,
totalAvatars: unknownMembers.members.count,
)
} else {
middleAvatar.configure(
with: unknownMembers.members[safe: 1]?.serviceId,
with: unknownMembers.members[safe: 1]?.aci,
totalAvatars: unknownMembers.members.count,
)
backAvatar.configure(
with: unknownMembers.members[safe: 2]?.serviceId,
with: unknownMembers.members[safe: 2]?.aci,
totalAvatars: unknownMembers.members.count,
)
}
}
}
// MARK: - CallControlsHeightObserver
// MARK: - Previews
extension CallDrawerSheet: CallControlsHeightObserver {
func callControlsHeightDidChange(newHeight: CGFloat) {
self.cancelAnimationAndUpdateConstraints()
self.animate {
self.setBottomSheetMinimizedHeight()
self.view.layoutIfNeeded()
}
#if DEBUG
@available(iOS 17, *)
#Preview("Group Call Member Cells") {
let dataSource = GroupCallMemberCellPreviewDataSource()
let tableView = UITableView(frame: .zero, style: .insetGrouped)
tableView.register(GroupCallMemberCell.self)
tableView.overrideUserInterfaceStyle = .dark
tableView.backgroundColor = UIColor(rgbHex: 0x1C1C1E)
tableView.dataSource = dataSource
tableView.allowsSelection = false
ObjectRetainer.retainObject(dataSource, forLifetimeOf: tableView)
return tableView
}
private class GroupCallMemberCellPreviewDataSource: NSObject, UITableViewDataSource {
struct CellConfig {
let aci = Aci.randomForTesting()
let name: String
let color: UIColor
let isAudioMuted: Bool
let isVideoMuted: Bool
let isPresenting: Bool
let isHandRaised: Bool
let isLocalUser: Bool
}
override open func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
self.setBottomSheetMinimizedHeight()
let configs: [CellConfig] = [
CellConfig(name: "Luke Skywalker", color: .systemBlue, isAudioMuted: false, isVideoMuted: false, isPresenting: false, isHandRaised: false, isLocalUser: false),
CellConfig(name: "Han Solo", color: .systemGreen, isAudioMuted: true, isVideoMuted: false, isPresenting: false, isHandRaised: false, isLocalUser: false),
CellConfig(name: "Leia Organa", color: .systemOrange, isAudioMuted: false, isVideoMuted: true, isPresenting: false, isHandRaised: false, isLocalUser: false),
CellConfig(name: "Obi-Wan Kenobi", color: .systemPurple, isAudioMuted: true, isVideoMuted: true, isPresenting: false, isHandRaised: false, isLocalUser: false),
CellConfig(name: "Padmé Amidala", color: .systemPink, isAudioMuted: false, isVideoMuted: false, isPresenting: true, isHandRaised: false, isLocalUser: false),
CellConfig(name: "Ahsoka Tano", color: .systemTeal, isAudioMuted: false, isVideoMuted: false, isPresenting: false, isHandRaised: true, isLocalUser: false),
CellConfig(name: "You", color: .systemRed, isAudioMuted: false, isVideoMuted: false, isPresenting: false, isHandRaised: true, isLocalUser: true),
CellConfig(name: "Chewbacca", color: .systemYellow, isAudioMuted: false, isVideoMuted: false, isPresenting: false, isHandRaised: false, isLocalUser: false),
CellConfig(name: "Lando Calrissian", color: .systemIndigo, isAudioMuted: true, isVideoMuted: false, isPresenting: false, isHandRaised: false, isLocalUser: false),
]
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
configs.count
}
private var bottomPadding: CGFloat {
max(self.view.safeAreaInsets.bottom + HeightConstants.bottomPadding, HeightConstants.minimumBottomPaddingIncludingSafeArea)
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(GroupCallMemberCell.self)!
let config = configs[indexPath.row]
private enum HeightConstants {
static let bottomPadding: CGFloat = 14
static let minimumBottomPaddingIncludingSafeArea: CGFloat = 30
static let initialTableInset: CGFloat = 25
static let titleViewBottomPadding: CGFloat = 16
static let tableViewTopPadding: CGFloat = 8
let member = CallDrawerSheet.JoinedMember(
id: .aci(config.aci),
aci: config.aci,
displayName: config.name,
comparableName: .nameValue(config.name),
avatarImage: .building.withTintColor(config.color),
demuxID: 0,
isLocalUser: config.isLocalUser,
isUnknown: false,
isAudioMuted: config.isAudioMuted,
isVideoMuted: config.isVideoMuted,
isPresenting: config.isPresenting,
)
let viewModel = GroupCallMemberCell.ViewModel(member: member)
cell.configure(
with: viewModel,
isHandRaised: config.isHandRaised,
)
return cell
}
}
#endif

View File

@ -22,9 +22,7 @@ protocol CallDrawerSheetDataSource {
typealias JoinedMember = CallDrawerSheet.JoinedMember
func unsortedMembers(tx: DBReadTransaction) -> [JoinedMember]
func raisedHandMemberIds() -> [JoinedMember.ID]
func raiseHand(raise: Bool)
func addObserver(_ observer: any CallDrawerSheetDataSourceObserver, syncStateImmediately: Bool)
func removeObserver(_ observer: any CallDrawerSheetDataSourceObserver)
@ -32,7 +30,7 @@ protocol CallDrawerSheetDataSource {
// MARK: - Group Call data source
final class GroupCallSheetDataSource<Call: GroupCall>: CallDrawerSheetDataSource {
final class GroupCallSheetDataSource<Call: GroupCall>: CallDrawerSheetDataSource, GroupCallObserver {
private let ringRtcCall: SignalRingRTC.GroupCall
private let groupCall: Call
@ -64,7 +62,19 @@ final class GroupCallSheetDataSource<Call: GroupCall>: CallDrawerSheetDataSource
}
func unsortedMembers(tx: DBReadTransaction) -> [JoinedMember] {
let avatarBuilder = SSKEnvironment.shared.avatarBuilderRef
let contactManager = SSKEnvironment.shared.contactManagerRef
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
func avatarImage(aci: Aci) -> UIImage? {
return avatarBuilder.avatarImage(
forAddress: SignalServiceAddress(aci),
diameterPoints: 36,
localUserDisplayMode: .asLocalUser,
transaction: tx,
)
}
guard let localIdentifiers = tsAccountManager.localIdentifiers(tx: tx) else {
return []
}
@ -82,16 +92,17 @@ final class GroupCallSheetDataSource<Call: GroupCall>: CallDrawerSheetDataSource
)
comparableName = .nameValue(resolvedName)
} else {
let displayName = SSKEnvironment.shared.contactManagerRef.displayName(for: member.address, tx: tx)
let displayName = contactManager.displayName(for: member.address, tx: tx)
resolvedName = displayName.resolvedValue(config: config.displayNameConfig)
comparableName = displayName.comparableValue(config: config)
}
return JoinedMember(
id: .demuxID(member.demuxId),
serviceId: member.aci,
aci: member.aci,
displayName: resolvedName,
comparableName: comparableName,
avatarImage: avatarImage(aci: member.aci),
demuxID: member.demuxId,
isLocalUser: false,
isUnknown: false,
@ -109,14 +120,15 @@ final class GroupCallSheetDataSource<Call: GroupCall>: CallDrawerSheetDataSource
id = .demuxID(localDemuxId)
demuxId = localDemuxId
} else {
id = .serviceId(localIdentifiers.aci)
id = .aci(localIdentifiers.aci)
demuxId = nil
}
members.append(JoinedMember(
id: id,
serviceId: localIdentifiers.aci,
aci: localIdentifiers.aci,
displayName: displayName,
comparableName: comparableName,
avatarImage: avatarImage(aci: localIdentifiers.aci),
demuxID: demuxId,
isLocalUser: true,
isUnknown: false,
@ -138,10 +150,11 @@ final class GroupCallSheetDataSource<Call: GroupCall>: CallDrawerSheetDataSource
true
}
return JoinedMember(
id: .serviceId(aci),
serviceId: aci,
id: .aci(aci),
aci: aci,
displayName: displayName.resolvedValue(config: config.displayNameConfig),
comparableName: displayName.comparableValue(config: config),
avatarImage: avatarImage(aci: aci),
demuxID: nil,
isLocalUser: false,
isUnknown: isUnknown,
@ -153,11 +166,9 @@ final class GroupCallSheetDataSource<Call: GroupCall>: CallDrawerSheetDataSource
}
return members
}
}
// MARK: GroupCallObserver
// MARK: GroupCallObserver
extension GroupCallSheetDataSource: GroupCallObserver {
func groupCallLocalDeviceStateChanged(_ call: GroupCall) {
AssertIsOnMainThread()
observers.elements.forEach { $0.callSheetMembershipDidChange(self) }
@ -182,10 +193,6 @@ extension GroupCallSheetDataSource: GroupCallObserver {
AssertIsOnMainThread()
observers.elements.forEach { $0.callSheetRaisedHandsDidChange(self) }
}
func raiseHand(raise: Bool) {
ringRtcCall.raiseHand(raise: raise)
}
}
// MARK: Call Links
@ -202,7 +209,7 @@ extension CallLinkSheetDataSource {
}
var isAdmin: Bool {
self.groupCall.adminPasskey != nil
self.groupCall.isAdmin
}
var adminPasskey: Data? {
@ -242,19 +249,33 @@ class IndividualCallSheetDataSource: CallDrawerSheetDataSource {
}
func unsortedMembers(tx: DBReadTransaction) -> [JoinedMember] {
let avatarBuilder = SSKEnvironment.shared.avatarBuilderRef
let contactManager = SSKEnvironment.shared.contactManagerRef
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
func avatarImage(aci: Aci) -> UIImage? {
return avatarBuilder.avatarImage(
forAddress: SignalServiceAddress(aci),
diameterPoints: 36,
localUserDisplayMode: .asLocalUser,
transaction: tx,
)
}
var members = [JoinedMember]()
if let remoteServiceId = thread.contactAddress.serviceId {
let remoteDisplayName = SSKEnvironment.shared.contactManagerRef.displayName(
if let remoteAci = thread.contactAddress.serviceId as? Aci {
let remoteDisplayName = contactManager.displayName(
for: thread.contactAddress,
tx: tx,
).resolvedValue()
let remoteComparableName: DisplayName.ComparableValue = .nameValue(remoteDisplayName)
members.append(JoinedMember(
id: .serviceId(remoteServiceId),
serviceId: remoteServiceId,
id: .aci(remoteAci),
aci: remoteAci,
displayName: remoteDisplayName,
comparableName: remoteComparableName,
avatarImage: avatarImage(aci: remoteAci),
demuxID: nil,
isLocalUser: false,
isUnknown: false,
@ -267,12 +288,13 @@ class IndividualCallSheetDataSource: CallDrawerSheetDataSource {
// Add yourself
let displayName = CommonStrings.you
let comparableName: DisplayName.ComparableValue = .nameValue(displayName)
if let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: tx)?.aci {
if let localAci = tsAccountManager.localIdentifiers(tx: tx)?.aci {
members.append(JoinedMember(
id: .serviceId(localAci),
serviceId: localAci,
id: .aci(localAci),
aci: localAci,
displayName: displayName,
comparableName: comparableName,
avatarImage: avatarImage(aci: localAci),
demuxID: nil,
isLocalUser: true,
isUnknown: false,
@ -285,9 +307,6 @@ class IndividualCallSheetDataSource: CallDrawerSheetDataSource {
}
func raisedHandMemberIds() -> [JoinedMember.ID] { [] }
func raiseHand(raise: Bool) {
owsFailDebug("Should not be able to raise hand in individual call")
}
private var observers: WeakArray<any CallDrawerSheetDataSourceObserver> = []
func addObserver(_ observer: any CallDrawerSheetDataSourceObserver, syncStateImmediately: Bool = false) {

View File

@ -1,82 +0,0 @@
//
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import LibSignalClient
import SignalRingRTC
import SignalServiceKit
import SignalUI
import UIKit
enum GroupCallContextMenuActionsBuilder {
static func build(
demuxId: SignalRingRTC.DemuxId,
contactAci: Aci,
isAudioMuted: Bool,
ringRtcGroupCall: SignalRingRTC.GroupCall,
) -> [UIAction] {
var contextMenuActions: [UIAction] = []
if
BuildFlags.RemoteMute.send,
!isAudioMuted
{
contextMenuActions.append(UIAction(
title: OWSLocalizedString(
"GROUP_CALL_CONTEXT_MENU_MUTE_AUDIO",
comment: "Context menu action to mute a call participant's audio.",
),
image: .micSlash,
handler: { [weak ringRtcGroupCall] _ in
guard let ringRtcGroupCall else { return }
MainActor.assumeIsolated {
ringRtcGroupCall.sendRemoteMuteRequest(demuxId)
}
},
))
}
contextMenuActions.append(UIAction(
title: OWSLocalizedString(
"GROUP_CALL_CONTEXT_MENU_GO_TO_CHAT",
comment: "Context menu action to navigate to the chat with a call participant.",
),
image: .arrowSquareUprightLight,
handler: { _ in
MainActor.assumeIsolated {
AppEnvironment.shared.windowManagerRef.minimizeCallIfNeeded()
SignalApp.shared.presentConversationForAddress(
SignalServiceAddress(contactAci),
animated: true,
)
}
},
))
contextMenuActions.append(UIAction(
title: OWSLocalizedString(
"GROUP_CALL_CONTEXT_MENU_PROFILE_DETAILS",
comment: "Context menu action to view a call participant's profile details.",
),
image: .personCircle,
handler: { _ in
guard let frontmostVC = CurrentAppContext().frontmostViewController() else {
return
}
MainActor.assumeIsolated {
AppEnvironment.shared.windowManagerRef.minimizeCallIfNeeded()
ProfileSheetSheetCoordinator(
address: SignalServiceAddress(contactAci),
groupViewHelper: nil,
spoilerState: SpoilerRenderState(),
).presentAppropriateSheet(from: frontmostVC)
}
},
))
return contextMenuActions
}
}

View File

@ -3,13 +3,17 @@
// SPDX-License-Identifier: AGPL-3.0-only
//
import LibSignalClient
import SignalRingRTC
import SignalServiceKit
import SignalUI
import UIKit
enum GroupCallVideoContextMenuConfiguration {
private static var contactManager: ContactManager { SSKEnvironment.shared.contactManagerRef }
private static var db: DB { DependenciesBridge.shared.db }
private static var tsAccountManager: TSAccountManager { DependenciesBridge.shared.tsAccountManager }
private static var windowManager: WindowManager { AppEnvironment.shared.windowManagerRef }
static func build(
call: SignalCall,
@ -18,39 +22,206 @@ enum GroupCallVideoContextMenuConfiguration {
remoteDevice: RemoteDeviceState,
interactionProvider: @escaping () -> UIContextMenuInteraction?,
) -> UIContextMenuConfiguration {
return build(
call: call,
groupCall: groupCall,
ringRtcCall: ringRtcCall,
demuxId: remoteDevice.demuxId,
aci: remoteDevice.aci,
isAudioMuted: remoteDevice.audioMuted,
interactionProvider: interactionProvider,
)
}
static func build(
call: SignalCall,
groupCall: GroupCall,
ringRtcCall: SignalRingRTC.GroupCall,
demuxId: DemuxId,
aci: Aci,
isAudioMuted: Bool?,
interactionProvider: @escaping () -> UIContextMenuInteraction?,
) -> UIContextMenuConfiguration {
let displayName: String = db.read { tx in
return contactManager.displayName(
for: SignalServiceAddress(aci),
tx: tx,
).resolvedValue()
}
return UIContextMenuConfiguration(
previewProvider: {
// A dedicated "call member" preview lets us avoid issues with
// cell reuse, add/remove, etc in the various group-call video
// collection views.
return GroupCallVideoContextMenuPreviewController(
demuxId: remoteDevice.demuxId,
demuxId: demuxId,
aci: aci,
displayName: displayName,
call: call,
groupCall: groupCall,
interactionProvider: interactionProvider,
)
},
actionProvider: { _ in
let contactDisplayName: DisplayName = db.read { tx in
return contactManager.displayName(
for: SignalServiceAddress(remoteDevice.aci),
tx: tx,
)
}
let actions = GroupCallContextMenuActionsBuilder.build(
demuxId: remoteDevice.demuxId,
contactAci: remoteDevice.aci,
isAudioMuted: remoteDevice.audioMuted == true,
let actions = contextMenuActions(
demuxId: demuxId,
aci: aci,
displayName: displayName,
isAudioMuted: isAudioMuted == true,
groupCall: groupCall,
ringRtcGroupCall: ringRtcCall,
)
return UIMenu(
title: contactDisplayName.resolvedValue(),
title: displayName,
children: actions,
)
},
)
}
static func contextMenuActions(
demuxId: SignalRingRTC.DemuxId,
aci: Aci,
displayName: String,
isAudioMuted: Bool,
groupCall: GroupCall,
ringRtcGroupCall: SignalRingRTC.GroupCall,
) -> [UIAction] {
var contextMenuActions: [UIAction] = []
if
BuildFlags.RemoteMute.send,
!isAudioMuted
{
contextMenuActions.append(UIAction(
title: OWSLocalizedString(
"GROUP_CALL_CONTEXT_MENU_MUTE_AUDIO",
comment: "Context menu action to mute a call participant's audio.",
),
image: .micSlash,
handler: { [weak ringRtcGroupCall] _ in
guard let ringRtcGroupCall else { return }
MainActor.assumeIsolated {
ringRtcGroupCall.sendRemoteMuteRequest(demuxId)
}
},
))
}
contextMenuActions.append(UIAction(
title: OWSLocalizedString(
"GROUP_CALL_CONTEXT_MENU_GO_TO_CHAT",
comment: "Context menu action to navigate to the chat with a call participant.",
),
image: .arrowSquareUprightLight,
handler: { _ in
MainActor.assumeIsolated {
windowManager.minimizeCallIfNeeded()
SignalApp.shared.presentConversationForAddress(
SignalServiceAddress(aci),
animated: true,
)
}
},
))
contextMenuActions.append(UIAction(
title: OWSLocalizedString(
"GROUP_CALL_CONTEXT_MENU_PROFILE_DETAILS",
comment: "Context menu action to view a call participant's profile details.",
),
image: .personCircle,
handler: { _ in
guard let frontmostVC = CurrentAppContext().frontmostViewController() else {
return
}
MainActor.assumeIsolated {
windowManager.minimizeCallIfNeeded()
ProfileSheetSheetCoordinator(
address: SignalServiceAddress(aci),
groupViewHelper: nil,
spoilerState: SpoilerRenderState(),
).presentAppropriateSheet(from: frontmostVC)
}
},
))
if
let callLinkCall = groupCall as? CallLinkCall,
callLinkCall.isAdmin,
let localIdentifiers = tsAccountManager.localIdentifiersWithMaybeSneakyTransaction,
!localIdentifiers.contains(serviceId: aci)
{
contextMenuActions.append(UIAction(
title: OWSLocalizedString(
"GROUP_CALL_CONTEXT_MENU_REMOVE_FROM_CALL",
comment: "Context menu action to remove a call participant from the call.",
),
image: .minusCircle,
attributes: .destructive,
handler: { _ in
removeFromCallWithConfirmation(
demuxId: demuxId,
displayName: displayName,
ringRtcGroupCall: ringRtcGroupCall,
)
},
))
}
return contextMenuActions
}
private static func removeFromCallWithConfirmation(
demuxId: DemuxId,
displayName: String,
ringRtcGroupCall: SignalRingRTC.GroupCall,
) {
let actionSheet = ActionSheetController(
title: String(
format: OWSLocalizedString(
"GROUP_CALL_REMOVE_MEMBER_CONFIRMATION_ACTION_SHEET_TITLE",
comment: "Title for action sheet confirming removal of a member from a group call. embeds {{ name }}",
),
displayName,
),
)
actionSheet.overrideUserInterfaceStyle = .dark
actionSheet.addAction(ActionSheetAction(
title: OWSLocalizedString(
"GROUP_CALL_REMOVE_MEMBER_CONFIRMATION_ACTION_SHEET_REMOVE_ACTION",
comment: "Label for the button to confirm removing a member from a group call.",
),
) { _ in
ringRtcGroupCall.removeClient(demuxId: demuxId)
})
actionSheet.addAction(ActionSheetAction(
title: OWSLocalizedString(
"GROUP_CALL_REMOVE_MEMBER_CONFIRMATION_ACTION_SHEET_BLOCK_ACTION",
comment: "Label for a button to block a member from a group call.",
),
) { _ in
ringRtcGroupCall.blockClient(demuxId: demuxId)
})
actionSheet.addAction(.cancel)
guard
let frontmostCallViewController = windowManager.callViewWindow
.findFrontmostViewController(ignoringAlerts: true)
else {
owsFailDebug("Missing frontmostViewController from call window: how?")
return
}
frontmostCallViewController.presentActionSheet(actionSheet)
}
}
// MARK: -
@ -58,6 +229,8 @@ enum GroupCallVideoContextMenuConfiguration {
/// Wraps a `CallMemberView` for the purposes of a context-menu preview.
private class GroupCallVideoContextMenuPreviewController: UIViewController, GroupCallObserver {
private let demuxId: DemuxId
private let aci: Aci
private let displayName: String
private let interactionProvider: () -> UIContextMenuInteraction?
private weak var call: SignalCall?
@ -67,11 +240,15 @@ private class GroupCallVideoContextMenuPreviewController: UIViewController, Grou
init(
demuxId: DemuxId,
aci: Aci,
displayName: String,
call: SignalCall,
groupCall: GroupCall,
interactionProvider: @escaping () -> UIContextMenuInteraction?,
) {
self.demuxId = demuxId
self.aci = aci
self.displayName = displayName
self.call = call
self.groupCall = groupCall
self.interactionProvider = interactionProvider
@ -122,10 +299,12 @@ private class GroupCallVideoContextMenuPreviewController: UIViewController, Grou
callMemberView.configure(call: call, remoteGroupMemberDeviceState: remoteDevice)
if let interaction = interactionProvider() {
let actions = GroupCallContextMenuActionsBuilder.build(
demuxId: remoteDevice.demuxId,
contactAci: remoteDevice.aci,
let actions = GroupCallVideoContextMenuConfiguration.contextMenuActions(
demuxId: demuxId,
aci: aci,
displayName: displayName,
isAudioMuted: remoteDevice.audioMuted == true,
groupCall: groupCall,
ringRtcGroupCall: groupCall.ringRtcCall,
)

View File

@ -3748,6 +3748,9 @@
/* Context menu action to view a call participant's profile details. */
"GROUP_CALL_CONTEXT_MENU_PROFILE_DETAILS" = "Profile Details";
/* Context menu action to remove a call participant from the call. */
"GROUP_CALL_CONTEXT_MENU_REMOVE_FROM_CALL" = "Remove from Call";
/* Button to continue an ongoing group call */
"GROUP_CALL_CONTINUE_BUTTON" = "Continue Call";
@ -3784,6 +3787,9 @@
/* Title for the section of the group call member list which displays the list of all members in the call. */
"GROUP_CALL_MEMBER_LIST_IN_CALL_SECTION_HEADER" = "In Call";
/* Subtitle for a row representing a call member, when that member is presenting. */
"GROUP_CALL_MEMBER_LIST_PRESENTING_SUBTITLE" = "Presenting";
/* Title for the section of the group call member list which displays the list of members with their hand raised. */
"GROUP_CALL_MEMBER_LIST_RAISED_HANDS_SECTION_HEADER" = "Raised Hands";

View File

@ -98,6 +98,8 @@ public enum SignalSymbol: Character {
case refresh = "\u{E0C4}"
case reply = "\u{E06D}"
case safetyNumber = "\u{E06F}"
case shareScreen = "\u{E171}"
case shareScreenFill = "\u{E174}"
case signal = "\u{E000}"
case spam = "\u{E033}"
case sticker = "\u{E070}"