474 lines
17 KiB
Swift
474 lines
17 KiB
Swift
//
|
|
// Copyright 2020 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import LibSignalClient
|
|
import SignalRingRTC
|
|
import SignalServiceKit
|
|
import SignalUI
|
|
import Combine
|
|
|
|
// MARK: - GroupCallMemberSheet
|
|
|
|
class GroupCallMemberSheet: InteractiveSheetViewController {
|
|
|
|
// MARK: Properties
|
|
|
|
override var interactiveScrollViews: [UIScrollView] { [tableView] }
|
|
|
|
private let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
|
private let call: SignalCall
|
|
private let ringRtcCall: SignalRingRTC.GroupCall
|
|
private let groupThreadCall: GroupThreadCall
|
|
|
|
override var sheetBackgroundColor: UIColor {
|
|
self.tableView.backgroundColor ?? .systemGroupedBackground
|
|
}
|
|
|
|
init(call: SignalCall, groupThreadCall: GroupThreadCall) {
|
|
self.call = call
|
|
self.ringRtcCall = groupThreadCall.ringRtcCall
|
|
self.groupThreadCall = groupThreadCall
|
|
|
|
super.init(blurEffect: nil)
|
|
|
|
self.overrideUserInterfaceStyle = .dark
|
|
groupThreadCall.addObserverAndSyncState(self)
|
|
}
|
|
|
|
// MARK: - Table setup
|
|
|
|
private typealias DiffableDataSource = UITableViewDiffableDataSource<Section, RowID>
|
|
private typealias Snapshot = NSDiffableDataSourceSnapshot<Section, RowID>
|
|
|
|
private enum Section: Hashable {
|
|
case raisedHands
|
|
case inCall
|
|
}
|
|
|
|
private struct RowID: Hashable {
|
|
var section: Section
|
|
var memberID: JoinedMember.ID
|
|
}
|
|
|
|
private lazy var dataSource = DiffableDataSource(
|
|
tableView: tableView
|
|
) { [weak self] tableView, indexPath, id -> UITableViewCell? in
|
|
guard let cell = tableView.dequeueReusableCell(GroupCallMemberCell.self, for: indexPath) else { return nil }
|
|
|
|
guard let viewModel = self?.viewModelsByID[id.memberID] else {
|
|
owsFailDebug("missing view model")
|
|
return cell
|
|
}
|
|
|
|
cell.configure(with: viewModel, isHandRaised: id.section == .raisedHands)
|
|
|
|
return cell
|
|
}
|
|
|
|
private class HeaderView: UIView {
|
|
private let section: Section
|
|
var memberCount: Int = 0 {
|
|
didSet {
|
|
self.updateText()
|
|
}
|
|
}
|
|
|
|
private let label = UILabel()
|
|
|
|
init(section: Section) {
|
|
self.section = section
|
|
super.init(frame: .zero)
|
|
|
|
self.addSubview(self.label)
|
|
self.label.autoPinEdgesToSuperviewMargins()
|
|
self.updateText()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
private func updateText() {
|
|
let titleText: String = switch section {
|
|
case .raisedHands:
|
|
OWSLocalizedString(
|
|
"GROUP_CALL_MEMBER_LIST_RAISED_HANDS_SECTION_HEADER",
|
|
comment: "Title for the section of the group call member list which displays the list of members with their hand raised."
|
|
)
|
|
case .inCall:
|
|
OWSLocalizedString(
|
|
"GROUP_CALL_MEMBER_LIST_IN_CALL_SECTION_HEADER",
|
|
comment: "Title for the section of the group call member list which displays the list of all members in the call."
|
|
)
|
|
}
|
|
|
|
label.attributedText = .composed(of: [
|
|
titleText.styled(with: .font(.dynamicTypeHeadline)),
|
|
" ",
|
|
String(
|
|
format: OWSLocalizedString(
|
|
"GROUP_CALL_MEMBER_LIST_SECTION_HEADER_MEMBER_COUNT",
|
|
comment: "A count of members in a given group call member list section, displayed after the header."
|
|
),
|
|
self.memberCount
|
|
)
|
|
]).styled(
|
|
with: .font(.dynamicTypeBody),
|
|
.color(Theme.darkThemePrimaryColor)
|
|
)
|
|
}
|
|
}
|
|
|
|
private let raisedHandsHeader = HeaderView(section: .raisedHands)
|
|
private let inCallHeader = HeaderView(section: .inCall)
|
|
|
|
override public func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
tableView.delegate = self
|
|
tableView.tableHeaderView = UIView(frame: CGRect(origin: .zero, size: CGSize(width: 0, height: CGFloat.leastNormalMagnitude)))
|
|
contentView.addSubview(tableView)
|
|
tableView.autoPinEdgesToSuperviewEdges()
|
|
|
|
tableView.register(GroupCallMemberCell.self, forCellReuseIdentifier: GroupCallMemberCell.reuseIdentifier)
|
|
|
|
tableView.dataSource = self.dataSource
|
|
|
|
updateMembers()
|
|
}
|
|
|
|
// MARK: - Table contents
|
|
|
|
fileprivate struct JoinedMember {
|
|
enum ID: Hashable {
|
|
case aci(Aci)
|
|
case demuxID(DemuxId)
|
|
}
|
|
|
|
let id: ID
|
|
|
|
let aci: Aci
|
|
let displayName: String
|
|
let comparableName: DisplayName.ComparableValue
|
|
let demuxID: DemuxId?
|
|
let isAudioMuted: Bool?
|
|
let isVideoMuted: Bool?
|
|
let isPresenting: Bool?
|
|
}
|
|
|
|
private var viewModelsByID: [JoinedMember.ID: GroupCallMemberCell.ViewModel] = [:]
|
|
private var sortedMembers = [JoinedMember]() {
|
|
didSet {
|
|
let oldMemberIDs = viewModelsByID.keys
|
|
let newMemberIDs = sortedMembers.map(\.id)
|
|
let viewModelsToRemove = Set(oldMemberIDs).subtracting(newMemberIDs)
|
|
viewModelsToRemove.forEach { viewModelsByID.removeValue(forKey: $0) }
|
|
|
|
viewModelsByID = sortedMembers.reduce(into: viewModelsByID) { partialResult, member in
|
|
if let existingViewModel = partialResult[member.id] {
|
|
existingViewModel.update(using: member)
|
|
} else {
|
|
partialResult[member.id] = .init(member: member)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func updateMembers() {
|
|
let unsortedMembers: [JoinedMember] = databaseStorage.read { transaction -> [JoinedMember] in
|
|
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
|
|
guard let localIdentifiers = tsAccountManager.localIdentifiers(tx: transaction.asV2Read) else {
|
|
return []
|
|
}
|
|
|
|
var members = [JoinedMember]()
|
|
let config: DisplayName.ComparableValue.Config = .current()
|
|
if self.ringRtcCall.localDeviceState.joinState == .joined {
|
|
members += self.ringRtcCall.remoteDeviceStates.values.map { member in
|
|
let resolvedName: String
|
|
let comparableName: DisplayName.ComparableValue
|
|
if member.aci == localIdentifiers.aci {
|
|
resolvedName = OWSLocalizedString(
|
|
"GROUP_CALL_YOU_ON_ANOTHER_DEVICE",
|
|
comment: "Text describing the local user in the group call members sheet when connected from another device."
|
|
)
|
|
comparableName = .nameValue(resolvedName)
|
|
} else {
|
|
let displayName = self.contactsManager.displayName(for: member.address, tx: transaction)
|
|
resolvedName = displayName.resolvedValue(config: config.displayNameConfig)
|
|
comparableName = displayName.comparableValue(config: config)
|
|
}
|
|
|
|
return JoinedMember(
|
|
id: .demuxID(member.demuxId),
|
|
aci: member.aci,
|
|
displayName: resolvedName,
|
|
comparableName: comparableName,
|
|
demuxID: member.demuxId,
|
|
isAudioMuted: member.audioMuted,
|
|
isVideoMuted: member.videoMuted,
|
|
isPresenting: member.presenting
|
|
)
|
|
}
|
|
|
|
let displayName = CommonStrings.you
|
|
let comparableName: DisplayName.ComparableValue = .nameValue(displayName)
|
|
let id: JoinedMember.ID
|
|
let demuxId: UInt32?
|
|
if let localDemuxId = groupThreadCall.ringRtcCall.localDeviceState.demuxId {
|
|
id = .demuxID(localDemuxId)
|
|
demuxId = localDemuxId
|
|
} else {
|
|
id = .aci(localIdentifiers.aci)
|
|
demuxId = nil
|
|
}
|
|
members.append(JoinedMember(
|
|
id: id,
|
|
aci: localIdentifiers.aci,
|
|
displayName: displayName,
|
|
comparableName: comparableName,
|
|
demuxID: demuxId,
|
|
isAudioMuted: self.ringRtcCall.isOutgoingAudioMuted,
|
|
isVideoMuted: self.ringRtcCall.isOutgoingVideoMuted,
|
|
isPresenting: false
|
|
))
|
|
} else {
|
|
// If we're not yet in the call, `remoteDeviceStates` will not exist.
|
|
// We can get the list of joined members still, provided we are connected.
|
|
members += self.ringRtcCall.peekInfo?.joinedMembers.map { aciUuid in
|
|
let aci = Aci(fromUUID: aciUuid)
|
|
let address = SignalServiceAddress(aci)
|
|
let displayName = self.contactsManager.displayName(for: address, tx: transaction)
|
|
return JoinedMember(
|
|
id: .aci(aci),
|
|
aci: aci,
|
|
displayName: displayName.resolvedValue(config: config.displayNameConfig),
|
|
comparableName: displayName.comparableValue(config: config),
|
|
demuxID: nil,
|
|
isAudioMuted: nil,
|
|
isVideoMuted: nil,
|
|
isPresenting: nil
|
|
)
|
|
} ?? []
|
|
}
|
|
|
|
return members
|
|
}
|
|
|
|
sortedMembers = unsortedMembers.sorted {
|
|
let nameComparison = $0.comparableName.isLessThanOrNilIfEqual($1.comparableName)
|
|
if let nameComparison {
|
|
return nameComparison
|
|
}
|
|
if $0.aci != $1.aci {
|
|
return $0.aci < $1.aci
|
|
}
|
|
return $0.demuxID ?? 0 < $1.demuxID ?? 0
|
|
}
|
|
|
|
self.updateSnapshotAndHeaders()
|
|
}
|
|
|
|
private func updateSnapshotAndHeaders() {
|
|
var snapshot = Snapshot()
|
|
|
|
if !groupThreadCall.raisedHands.isEmpty {
|
|
snapshot.appendSections([.raisedHands])
|
|
snapshot.appendItems(
|
|
groupThreadCall.raisedHands.map {
|
|
RowID(section: .raisedHands, memberID: .demuxID($0))
|
|
},
|
|
toSection: .raisedHands
|
|
)
|
|
|
|
raisedHandsHeader.memberCount = groupThreadCall.raisedHands.count
|
|
}
|
|
|
|
snapshot.appendSections([.inCall])
|
|
snapshot.appendItems(
|
|
sortedMembers.map { RowID(section: .inCall, memberID: $0.id) },
|
|
toSection: .inCall
|
|
)
|
|
|
|
inCallHeader.memberCount = sortedMembers.count
|
|
|
|
dataSource.apply(snapshot, animatingDifferences: true)
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: UITableViewDelegate
|
|
|
|
extension GroupCallMemberSheet: UITableViewDelegate {
|
|
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
|
if section == 0, !groupThreadCall.raisedHands.isEmpty {
|
|
return raisedHandsHeader
|
|
} else {
|
|
return inCallHeader
|
|
}
|
|
}
|
|
|
|
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
|
|
return UITableView.automaticDimension
|
|
}
|
|
|
|
func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
|
|
return .leastNormalMagnitude
|
|
}
|
|
}
|
|
|
|
// MARK: CallObserver
|
|
|
|
extension GroupCallMemberSheet: GroupCallObserver {
|
|
func groupCallLocalDeviceStateChanged(_ call: GroupCall) {
|
|
AssertIsOnMainThread()
|
|
updateMembers()
|
|
}
|
|
|
|
func groupCallRemoteDeviceStatesChanged(_ call: GroupCall) {
|
|
AssertIsOnMainThread()
|
|
updateMembers()
|
|
}
|
|
|
|
func groupCallPeekChanged(_ call: GroupCall) {
|
|
AssertIsOnMainThread()
|
|
updateMembers()
|
|
}
|
|
|
|
func groupCallEnded(_ call: GroupCall, reason: GroupCallEndReason) {
|
|
AssertIsOnMainThread()
|
|
updateMembers()
|
|
}
|
|
|
|
func groupCallReceivedRaisedHands(_ call: GroupCall, raisedHands: [DemuxId]) {
|
|
AssertIsOnMainThread()
|
|
updateSnapshotAndHeaders()
|
|
}
|
|
}
|
|
|
|
// MARK: - GroupCallMemberCell
|
|
|
|
private class GroupCallMemberCell: UITableViewCell, ReusableTableViewCell {
|
|
|
|
// MARK: ViewModel
|
|
|
|
class ViewModel {
|
|
typealias Member = GroupCallMemberSheet.JoinedMember
|
|
|
|
let aci: Aci
|
|
let name: String
|
|
|
|
@Published var shouldShowAudioMutedIcon = false
|
|
@Published var shouldShowVideoMutedIcon = false
|
|
@Published var shouldShowPresentingIcon = false
|
|
|
|
init(member: Member) {
|
|
self.aci = member.aci
|
|
self.name = member.displayName
|
|
self.update(using: member)
|
|
}
|
|
|
|
func update(using member: Member) {
|
|
owsAssertDebug(aci == member.aci)
|
|
self.shouldShowAudioMutedIcon = member.isAudioMuted ?? false
|
|
self.shouldShowVideoMutedIcon = member.isVideoMuted == true && member.isPresenting != true
|
|
self.shouldShowPresentingIcon = member.isPresenting ?? false
|
|
}
|
|
}
|
|
|
|
// MARK: Properties
|
|
|
|
static let reuseIdentifier = "GroupCallMemberCell"
|
|
|
|
let avatarView = ConversationAvatarView(
|
|
sizeClass: .thirtySix,
|
|
localUserDisplayMode: .asUser,
|
|
badged: false
|
|
)
|
|
let nameLabel = UILabel()
|
|
let videoMutedIndicator = UIImageView()
|
|
let audioMutedIndicator = UIImageView()
|
|
let presentingIndicator = UIImageView()
|
|
let raisedHandIndicator = UIImageView()
|
|
|
|
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
|
|
|
|
func setup(iconView: UIImageView, withImageNamed imageName: String, in wrapper: UIView) {
|
|
iconView.setTemplateImageName(imageName, tintColor: Theme.darkThemeSecondaryTextAndIconColor)
|
|
wrapper.addSubview(iconView)
|
|
iconView.autoPinEdgesToSuperviewEdges()
|
|
}
|
|
|
|
let trailingWrapper = UIView()
|
|
setup(iconView: audioMutedIndicator, withImageNamed: "mic-slash", in: trailingWrapper)
|
|
setup(iconView: raisedHandIndicator, withImageNamed: "raise_hand", in: trailingWrapper)
|
|
|
|
let leadingWrapper = UIView()
|
|
setup(iconView: videoMutedIndicator, withImageNamed: "video-slash", in: leadingWrapper)
|
|
setup(iconView: presentingIndicator, withImageNamed: "share_screen", in: leadingWrapper)
|
|
|
|
let stackView = UIStackView(arrangedSubviews: [
|
|
avatarView,
|
|
UIView.spacer(withWidth: 12),
|
|
nameLabel,
|
|
UIView.spacer(withWidth: 16),
|
|
leadingWrapper,
|
|
UIView.spacer(withWidth: 16),
|
|
trailingWrapper
|
|
])
|
|
stackView.axis = .horizontal
|
|
stackView.alignment = .center
|
|
contentView.addSubview(stackView)
|
|
stackView.autoPinEdgesToSuperviewMargins()
|
|
}
|
|
|
|
required init?(coder aDecoder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
// MARK: Configuration
|
|
|
|
// 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.
|
|
func configure(with viewModel: ViewModel, isHandRaised: Bool) {
|
|
self.subscriptions.removeAll()
|
|
|
|
if isHandRaised {
|
|
self.raisedHandIndicator.isHidden = false
|
|
self.audioMutedIndicator.isHidden = true
|
|
self.videoMutedIndicator.isHidden = true
|
|
self.presentingIndicator.isHidden = true
|
|
} else {
|
|
self.raisedHandIndicator.isHidden = true
|
|
self.subscribe(to: viewModel.$shouldShowAudioMutedIcon, showing: self.audioMutedIndicator)
|
|
self.subscribe(to: viewModel.$shouldShowVideoMutedIcon, showing: self.videoMutedIndicator)
|
|
self.subscribe(to: viewModel.$shouldShowPresentingIcon, showing: self.presentingIndicator)
|
|
}
|
|
|
|
self.nameLabel.text = viewModel.name
|
|
self.avatarView.updateWithSneakyTransactionIfNecessary { config in
|
|
config.dataSource = .address(SignalServiceAddress(viewModel.aci))
|
|
}
|
|
}
|
|
|
|
private func subscribe(to publisher: Published<Bool>.Publisher, showing view: UIView) {
|
|
publisher
|
|
.removeDuplicates()
|
|
.sink { [weak view] shouldShow in
|
|
view?.isHidden = !shouldShow
|
|
}
|
|
.store(in: &self.subscriptions)
|
|
}
|
|
|
|
}
|