Redesigned CallDrawerSheet member cells
This commit is contained in:
parent
ebc5d18187
commit
9d17d346f5
@ -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 */,
|
||||
|
||||
@ -30,6 +30,10 @@ final class CallLinkCall: Signal.GroupCall {
|
||||
)
|
||||
}
|
||||
|
||||
var isAdmin: Bool {
|
||||
adminPasskey != nil
|
||||
}
|
||||
|
||||
var mayNeedToAskToJoin: Bool {
|
||||
return callLinkState.requiresAdminApproval && adminPasskey == nil
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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}"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user