element-ios/Riot/Modules/Room/TimelineCells/Call/Group/RoomGroupCallStatusCell.swift

333 lines
13 KiB
Swift

//
// Copyright 2021-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import UIKit
/// The number of milliseconds in one second.
private let MSEC_PER_SEC: TimeInterval = 1000
@objcMembers
class RoomGroupCallStatusCell: RoomCallBaseCell {
private static var className: String {
return String(describing: self)
}
/// Action identifier used when the user pressed "Join" button for an active call.
/// The `userInfo` dictionary contains an `MXEvent` object under the `kMXKRoomBubbleCellEventKey` key, representing the widget event of the call.
static var joinAction: String {
return self.className + ".join"
}
/// Action identifier used when the user pressed "Leave" button for an active call.
/// The `userInfo` dictionary contains an `MXEvent` object under the `kMXKRoomBubbleCellEventKey` key, representing the widget event of the call.
static var leaveAction: String {
return self.className + ".leave"
}
/// Action identifier used when the user pressed "Answer" button for an incoming call.
/// The `userInfo` dictionary contains an `MXEvent` object under the `kMXKRoomBubbleCellEventKey` key, representing the widget event of the call.
static var answerAction: String {
return self.className + ".answer"
}
/// Action identifier used when the user pressed "Decline" button for an incoming call.
/// The `userInfo` dictionary contains an `MXEvent` object under the `kMXKRoomBubbleCellEventKey` key, representing the widget event of the call.
static var declineAction: String {
return self.className + ".decline"
}
private var callDurationString: String = ""
private var isIncoming: Bool = false
private var widgetEvent: MXEvent!
private var widgetId: String!
private var viewState: ViewState = .unknown {
didSet {
updateBottomContentView()
}
}
private enum Constants {
static let secondsToDisplayAnswerDeclineOptions: TimeInterval = 30
}
private enum ViewState {
case unknown
case ringing
case active
case declined
case ended
}
private static var callDurationFormatter: DateComponentsFormatter {
let formatter = DateComponentsFormatter()
formatter.zeroFormattingBehavior = .dropAll
formatter.allowedUnits = [.hour, .minute, .second]
formatter.unitsStyle = .abbreviated
return formatter
}
private func updateBottomContentView() {
bottomContentView = bottomView(for: viewState)
}
private var callTypeIcon: UIImage {
// always return a video call icon
return Asset.Images.callVideoIcon.image
}
private var isJoined: Bool {
return widgetId != nil &&
AppDelegate.theDelegate().callPresenter.jitsiVC?.widget.widgetId == widgetId
}
private var actionUserInfo: [AnyHashable: Any]? {
if let event = widgetEvent {
return [kMXKRoomBubbleCellEventKey: event]
}
return nil
}
private func bottomView(for state: ViewState) -> UIView? {
switch state {
case .unknown:
return nil
case .ringing:
let view = HorizontalButtonsContainerView.loadFromNib()
view.firstButton.style = .negative
view.firstButton.setTitle(VectorL10n.eventFormatterCallDecline, for: .normal)
view.firstButton.setImage(Asset.Images.voiceCallHangupIcon.image, for: .normal)
view.firstButton.removeTarget(nil, action: nil, for: .touchUpInside)
view.firstButton.addTarget(self, action: #selector(declineCallAction(_:)), for: .touchUpInside)
view.secondButton.style = .positive
view.secondButton.setTitle(VectorL10n.eventFormatterCallAnswer, for: .normal)
view.secondButton.setImage(callTypeIcon, for: .normal)
view.secondButton.removeTarget(nil, action: nil, for: .touchUpInside)
view.secondButton.addTarget(self, action: #selector(answerCallAction(_:)), for: .touchUpInside)
return view
case .active:
let view = HorizontalButtonsContainerView.loadFromNib()
view.secondButton.isHidden = true
if isJoined {
// show a "Leave" button
view.firstButton.style = .negative
view.firstButton.setTitle(VectorL10n.eventFormatterGroupCallLeave, for: .normal)
view.firstButton.setImage(nil, for: .normal)
view.firstButton.removeTarget(nil, action: nil, for: .touchUpInside)
view.firstButton.addTarget(self, action: #selector(leaveAction(_:)), for: .touchUpInside)
} else {
// show a "Join" button
view.firstButton.style = .positive
view.firstButton.setTitle(VectorL10n.eventFormatterGroupCallJoin, for: .normal)
view.firstButton.setImage(callTypeIcon, for: .normal)
view.firstButton.removeTarget(nil, action: nil, for: .touchUpInside)
view.firstButton.addTarget(self, action: #selector(joinAction(_:)), for: .touchUpInside)
}
return view
case .declined:
let view = HorizontalButtonsContainerView.loadFromNib()
view.secondButton.isHidden = true
view.firstButton.style = .positive
view.firstButton.setTitle(VectorL10n.eventFormatterGroupCallJoin, for: .normal)
view.firstButton.setImage(callTypeIcon, for: .normal)
view.firstButton.removeTarget(nil, action: nil, for: .touchUpInside)
view.firstButton.addTarget(self, action: #selector(joinAction(_:)), for: .touchUpInside)
return view
case .ended:
return nil
}
}
private func updateStatusTextForEndedCall() {
if callDurationString.count > 0 {
statusText = VectorL10n.eventFormatterCallHasEndedWithTime(callDurationString)
} else {
statusText = VectorL10n.eventFormatterCallHasEnded
}
}
// MARK: - Actions
@objc
private func joinAction(_ sender: CallTileActionButton) {
self.delegate?.cell(self,
didRecognizeAction: Self.joinAction,
userInfo: actionUserInfo)
}
@objc
private func leaveAction(_ sender: CallTileActionButton) {
self.delegate?.cell(self,
didRecognizeAction: Self.leaveAction,
userInfo: actionUserInfo)
}
@objc
private func declineCallAction(_ sender: CallTileActionButton) {
self.delegate?.cell(self,
didRecognizeAction: Self.declineAction,
userInfo: actionUserInfo)
}
@objc
private func answerCallAction(_ sender: CallTileActionButton) {
self.delegate?.cell(self,
didRecognizeAction: Self.answerAction,
userInfo: actionUserInfo)
}
// MARK: - MXKCellRendering
override func render(_ cellData: MXKCellData!) {
super.render(cellData)
viewState = .unknown
guard let bubbleCellData = cellData as? RoomBubbleCellData else {
return
}
let events = bubbleCellData.allLinkedEvents()
MXLog.debug("[RoomGroupCallStatusBubbleCell] render: \(events.count) events: \(events)")
guard let widgetEvent = events
.first(where: {
$0.eventType == .custom &&
($0.type == kWidgetMatrixEventTypeString || $0.type == kWidgetModularEventTypeString)
}) else {
return
}
guard let widgetId = widgetEvent.stateKey else {
return
}
guard let room = bubbleCellData.mxSession.room(withRoomId: widgetEvent.roomId) else {
return
}
callDurationString = readableCallDuration(from: widgetEvent, endEvent: nil)
isIncoming = widgetEvent.sender != bubbleCellData.mxSession.myUserId
self.widgetEvent = widgetEvent
self.widgetId = widgetId
innerContentView.callIconView.image = Asset.Images.callVideoIcon.image
if isIncoming && !isJoined &&
TimeInterval(widgetEvent.age)/MSEC_PER_SEC < Constants.secondsToDisplayAnswerDeclineOptions {
if JitsiService.shared.isWidgetDeclined(withId: widgetId) {
innerContentView.callerNameLabel.text = room.summary.displayName
room.summary.setRoomAvatarImageIn(innerContentView.avatarImageView)
viewState = .declined
statusText = VectorL10n.eventFormatterCallYouDeclined
} else {
innerContentView.callerNameLabel.text = VectorL10n.eventFormatterGroupCallIncoming(bubbleCellData.senderDisplayName, room.summary.displayName)
innerContentView.avatarImageView.setImageURI(bubbleCellData.senderAvatarUrl,
withType: nil,
andImageOrientation: .up,
toFitViewSize: innerContentView.avatarImageView.frame.size,
with: MXThumbnailingMethodCrop,
previewImage: bubbleCellData.senderAvatarPlaceholder,
mediaManager: bubbleCellData.mxSession.mediaManager)
viewState = .ringing
statusText = nil
}
} else {
innerContentView.callerNameLabel.text = room.summary.displayName
room.summary.setRoomAvatarImageIn(innerContentView.avatarImageView)
}
innerContentView.avatarImageView.defaultBackgroundColor = .clear
room.state { [weak self] (roomState) in
guard let self = self else { return }
guard let widgets = WidgetManager.shared()?.widgets(ofTypes: [
kWidgetTypeJitsiV1,
kWidgetTypeJitsiV2
],
in: room,
with: roomState) else {
self.viewState = .ended
self.updateStatusTextForEndedCall()
return
}
let removeWidgetEvent = roomState?.stateEvents
.filter({ $0.stateKey == widgetId })
.first(where: { $0.content.isEmpty })
self.callDurationString = self.readableCallDuration(from: widgetEvent,
endEvent: removeWidgetEvent)
guard let widget = widgets.first(where: { $0.widgetId == widgetId }) else {
self.viewState = .ended
self.updateStatusTextForEndedCall()
return
}
if widget.isActive {
if !self.isIncoming {
self.viewState = .active
self.statusText = VectorL10n.eventFormatterCallActiveVideo
} else if !self.isJoined &&
TimeInterval(widgetEvent.age)/MSEC_PER_SEC < Constants.secondsToDisplayAnswerDeclineOptions {
if JitsiService.shared.isWidgetDeclined(withId: widgetId) {
self.viewState = .declined
self.statusText = VectorL10n.eventFormatterCallYouDeclined
} else {
self.viewState = .ringing
self.statusText = nil
}
} else {
self.viewState = .active
self.statusText = VectorL10n.eventFormatterCallActiveVideo
}
} else {
self.viewState = .ended
self.updateStatusTextForEndedCall()
}
}
}
private func callDuration(from startEvent: MXEvent?, endEvent: MXEvent?) -> TimeInterval {
guard let startDate = startEvent?.originServerTs else {
// never started
return 0
}
guard let endDate = endEvent?.originServerTs else {
// not ended yet, compute the diff from now
return (NSTimeIntervalSince1970 - TimeInterval(startDate))/MSEC_PER_SEC
}
// ended, compute the diff between two dates
return TimeInterval(max(0, Double(endDate) - Double(startDate)))/MSEC_PER_SEC
}
private func readableCallDuration(from startEvent: MXEvent?, endEvent: MXEvent?) -> String {
let duration = callDuration(from: startEvent, endEvent: endEvent)
if duration <= 0 {
return ""
}
return RoomGroupCallStatusCell.callDurationFormatter.string(from: duration) ?? ""
}
}