зеркало из https://github.com/nextcloud/talk-ios.git
1956 строки
89 KiB
Swift
1956 строки
89 KiB
Swift
//
|
|
// SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
//
|
|
|
|
import Foundation
|
|
import NextcloudKit
|
|
import PhotosUI
|
|
import UIKit
|
|
import SwiftyAttributes
|
|
|
|
@objcMembers public class ChatViewController: BaseChatViewController {
|
|
|
|
// MARK: - Public var
|
|
public var presentedInCall = false
|
|
public var chatController: NCChatController
|
|
public var highlightMessageId = 0
|
|
|
|
// MARK: - Private var
|
|
private var hasPresentedLobby = false
|
|
private var hasRequestedInitialHistory = false
|
|
private var hasReceiveInitialHistory = false
|
|
private var retrievingHistory = false
|
|
|
|
private var hasJoinedRoom = false
|
|
private var startReceivingMessagesAfterJoin = false
|
|
private var offlineMode = false
|
|
private var hasStoredHistory = true
|
|
private var hasStopped = false
|
|
private var hasCheckedOutOfOfficeStatus = false
|
|
|
|
private var chatViewPresentedTimestamp = Date().timeIntervalSince1970
|
|
private var generateSummaryFromMessageId: Int?
|
|
private var generateSummaryTimer: Timer?
|
|
|
|
private lazy var unreadMessagesSeparator: NCChatMessage = {
|
|
let message = NCChatMessage()
|
|
|
|
message.messageId = MessageSeparatorTableViewCell.unreadMessagesSeparatorId
|
|
|
|
// We decide at this point if the unread marker should be with/without summary button, so it doesn't get changed when the room is updated
|
|
if !self.room.isFederated, NCDatabaseManager.sharedInstance().serverHasTalkCapability(kCapabilityChatSummary, forAccountId: self.room.accountId),
|
|
let serverCapabilities = NCDatabaseManager.sharedInstance().serverCapabilities(forAccountId: self.room.accountId),
|
|
serverCapabilities.summaryThreshold <= self.room.unreadMessages {
|
|
|
|
message.messageId = MessageSeparatorTableViewCell.unreadMessagesWithSummarySeparatorId
|
|
}
|
|
|
|
return message
|
|
}()
|
|
|
|
private lazy var lastReadMessage: Int = {
|
|
return self.room.lastReadMessage
|
|
}()
|
|
|
|
private var lobbyCheckTimer: Timer?
|
|
|
|
// MARK: - Call buttons in NavigationBar
|
|
|
|
func getBarButton(forVideo video: Bool) -> BarButtonItemWithActivity {
|
|
let symbolConfiguration = UIImage.SymbolConfiguration(pointSize: 20)
|
|
let buttonImage = UIImage(systemName: video ? "video" : "phone", withConfiguration: symbolConfiguration)
|
|
|
|
let button = BarButtonItemWithActivity(width: 50, with: buttonImage)
|
|
button.innerButton.addAction { [unowned self] in
|
|
button.showIndicator()
|
|
startCall(withVideo: video, silently: false, button: button)
|
|
}
|
|
|
|
if NCDatabaseManager.sharedInstance().roomHasTalkCapability(kCapabilitySilentCall, for: self.room) {
|
|
let silentCall = UIAction(title: NSLocalizedString("Call without notification", comment: ""), image: UIImage(systemName: "bell.slash")) { [unowned self] _ in
|
|
button.showIndicator()
|
|
startCall(withVideo: video, silently: true, button: button)
|
|
}
|
|
|
|
button.innerButton.menu = UIMenu(children: [silentCall])
|
|
}
|
|
|
|
return button
|
|
}
|
|
|
|
func startCall(withVideo video: Bool, silently: Bool, button: BarButtonItemWithActivity) {
|
|
if self.room.recordingConsent {
|
|
let alert = UIAlertController(title: "⚠️" + NSLocalizedString("The call might be recorded", comment: ""),
|
|
message: NSLocalizedString("The recording might include your voice, video from camera, and screen share. Your consent is required before joining the call.", comment: ""),
|
|
preferredStyle: .alert)
|
|
|
|
alert.addAction(.init(title: NSLocalizedString("Give consent and join call", comment: "Give consent to the recording of the call and join that call"), style: .default) { _ in
|
|
CallKitManager.sharedInstance().startCall(self.room.token, withVideoEnabled: video, andDisplayName: self.room.displayName, asInitiator: !self.room.hasCall, silently: silently, recordingConsent: true, withAccountId: self.room.accountId)
|
|
})
|
|
|
|
alert.addAction(.init(title: NSLocalizedString("Cancel", comment: ""), style: .cancel) { _ in
|
|
button.hideIndicator()
|
|
})
|
|
|
|
NCUserInterfaceController.sharedInstance().presentAlertViewController(alert)
|
|
|
|
} else {
|
|
CallKitManager.sharedInstance().startCall(self.room.token, withVideoEnabled: video, andDisplayName: self.room.displayName, asInitiator: !self.room.hasCall, silently: silently, recordingConsent: false, withAccountId: self.room.accountId)
|
|
}
|
|
}
|
|
|
|
private lazy var videoCallButton: BarButtonItemWithActivity = {
|
|
let videoCallButton = self.getBarButton(forVideo: true)
|
|
|
|
videoCallButton.accessibilityLabel = NSLocalizedString("Video call", comment: "")
|
|
videoCallButton.accessibilityHint = NSLocalizedString("Double tap to start a video call", comment: "")
|
|
|
|
return videoCallButton
|
|
}()
|
|
|
|
private lazy var voiceCallButton: BarButtonItemWithActivity = {
|
|
let voiceCallButton = self.getBarButton(forVideo: false)
|
|
|
|
voiceCallButton.accessibilityLabel = NSLocalizedString("Voice call", comment: "")
|
|
voiceCallButton.accessibilityHint = NSLocalizedString("Double tap to start a voice call", comment: "")
|
|
|
|
return voiceCallButton
|
|
}()
|
|
|
|
private var messageExpirationTimer: Timer?
|
|
|
|
public override init?(forRoom room: NCRoom, withAccount account: TalkAccount) {
|
|
self.chatController = NCChatController(for: room)
|
|
|
|
super.init(forRoom: room, withAccount: account)
|
|
|
|
NotificationCenter.default.addObserver(self, selector: #selector(didUpdateRoom(notification:)), name: NSNotification.Name.NCRoomsManagerDidUpdateRoom, object: nil)
|
|
NotificationCenter.default.addObserver(self, selector: #selector(didJoinRoom(notification:)), name: NSNotification.Name.NCRoomsManagerDidJoinRoom, object: nil)
|
|
NotificationCenter.default.addObserver(self, selector: #selector(didLeaveRoom(notification:)), name: NSNotification.Name.NCRoomsManagerDidLeaveRoom, object: nil)
|
|
NotificationCenter.default.addObserver(self, selector: #selector(didReceiveInitialChatHistory(notification:)), name: NSNotification.Name.NCChatControllerDidReceiveInitialChatHistory, object: nil)
|
|
NotificationCenter.default.addObserver(self, selector: #selector(didReceiveInitialChatHistoryOffline(notification:)), name: NSNotification.Name.NCChatControllerDidReceiveInitialChatHistoryOffline, object: nil)
|
|
NotificationCenter.default.addObserver(self, selector: #selector(didReceiveChatHistory(notification:)), name: NSNotification.Name.NCChatControllerDidReceiveChatHistory, object: nil)
|
|
NotificationCenter.default.addObserver(self, selector: #selector(didReceiveChatMessages(notification:)), name: NSNotification.Name.NCChatControllerDidReceiveChatMessages, object: nil)
|
|
NotificationCenter.default.addObserver(self, selector: #selector(didSendChatMessage(notification:)), name: NSNotification.Name.NCChatControllerDidSendChatMessage, object: nil)
|
|
NotificationCenter.default.addObserver(self, selector: #selector(didReceiveChatBlocked(notification:)), name: NSNotification.Name.NCChatControllerDidReceiveChatBlocked, object: nil)
|
|
NotificationCenter.default.addObserver(self, selector: #selector(didReceiveNewerCommonReadMessage(notification:)), name: NSNotification.Name.NCChatControllerDidReceiveNewerCommonReadMessage, object: nil)
|
|
NotificationCenter.default.addObserver(self, selector: #selector(didReceiveCallStartedMessage(notification:)), name: NSNotification.Name.NCChatControllerDidReceiveCallStartedMessage, object: nil)
|
|
NotificationCenter.default.addObserver(self, selector: #selector(didReceiveCallEndedMessage(notification:)), name: NSNotification.Name.NCChatControllerDidReceiveCallEndedMessage, object: nil)
|
|
NotificationCenter.default.addObserver(self, selector: #selector(didReceiveUpdateMessage(notification:)), name: NSNotification.Name.NCChatControllerDidReceiveUpdateMessage, object: nil)
|
|
NotificationCenter.default.addObserver(self, selector: #selector(didReceiveHistoryCleared(notification:)), name: NSNotification.Name.NCChatControllerDidReceiveHistoryCleared, object: nil)
|
|
NotificationCenter.default.addObserver(self, selector: #selector(didReceiveMessagesInBackground(notification:)), name: NSNotification.Name.NCChatControllerDidReceiveMessagesInBackground, object: nil)
|
|
NotificationCenter.default.addObserver(self, selector: #selector(didChangeRoomCapabilities(notification:)), name: NSNotification.Name.NCDatabaseManagerRoomCapabilitiesChanged, object: nil)
|
|
|
|
NotificationCenter.default.addObserver(self, selector: #selector(didReceiveParticipantJoin(notification:)), name: NSNotification.Name.NCExternalSignalingControllerDidReceiveJoinOfParticipant, object: nil)
|
|
NotificationCenter.default.addObserver(self, selector: #selector(didReceiveParticipantLeave(notification:)), name: NSNotification.Name.NCExternalSignalingControllerDidReceiveLeaveOfParticipant, object: nil)
|
|
NotificationCenter.default.addObserver(self, selector: #selector(didReceiveStartedTyping(notification:)), name: NSNotification.Name.NCExternalSignalingControllerDidReceiveStartedTyping, object: nil)
|
|
NotificationCenter.default.addObserver(self, selector: #selector(didReceiveStoppedTyping(notification:)), name: NSNotification.Name.NCExternalSignalingControllerDidReceiveStoppedTyping, object: nil)
|
|
NotificationCenter.default.addObserver(self, selector: #selector(didFailRequestingCallTransaction(notification:)), name: NSNotification.Name.CallKitManagerDidFailRequestingCallTransaction, object: nil)
|
|
NotificationCenter.default.addObserver(self, selector: #selector(didUpdateParticipants(notification:)), name: NSNotification.Name.NCExternalSignalingControllerDidUpdateParticipants, object: nil)
|
|
|
|
NotificationCenter.default.addObserver(self, selector: #selector(appDidBecomeActive(notification:)), name: UIApplication.didBecomeActiveNotification, object: nil)
|
|
NotificationCenter.default.addObserver(self, selector: #selector(appWillResignActive(notification:)), name: UIApplication.willResignActiveNotification, object: nil)
|
|
NotificationCenter.default.addObserver(self, selector: #selector(connectionStateHasChanged(notification:)), name: NSNotification.Name.NCConnectionStateHasChanged, object: nil)
|
|
NotificationCenter.default.addObserver(self, selector: #selector(maintenanceModeActive(notification:)), name: NSNotification.Name.NCServerMaintenanceMode, object: nil)
|
|
|
|
// Notifications when runing on Mac
|
|
NotificationCenter.default.addObserver(self, selector: #selector(appDidBecomeActive(notification:)), name: NSNotification.Name(rawValue: "NSApplicationDidBecomeActiveNotification"), object: nil)
|
|
NotificationCenter.default.addObserver(self, selector: #selector(appWillResignActive(notification:)), name: NSNotification.Name(rawValue: "NSApplicationDidResignActiveNotification"), object: nil)
|
|
}
|
|
|
|
deinit {
|
|
NotificationCenter.default.removeObserver(self)
|
|
print("Dealloc NewChatViewController")
|
|
}
|
|
|
|
// MARK: - View lifecycle
|
|
|
|
public override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
if room.supportsCalling {
|
|
self.navigationItem.rightBarButtonItems = [videoCallButton, voiceCallButton]
|
|
}
|
|
|
|
// No sharing options in federation v1
|
|
if room.isFederated {
|
|
// When hiding the button it is still respected in the layout constraints
|
|
// So we need to remove the image to remove the button for now
|
|
self.leftButton.setImage(nil, for: .normal)
|
|
}
|
|
|
|
// Disable room info, input bar and call buttons until joining room
|
|
self.disableRoomControls()
|
|
}
|
|
|
|
public override func viewWillAppear(_ animated: Bool) {
|
|
super.viewWillAppear(animated)
|
|
|
|
self.checkLobbyState()
|
|
self.checkRoomControlsAvailability()
|
|
self.checkOutOfOfficeAbsence()
|
|
|
|
self.startObservingExpiredMessages()
|
|
|
|
// Workaround for open conversations:
|
|
// We can't get initial chat history until we join the conversation (since we are not a participant until then)
|
|
// So for rooms that we don't know the last read message we wait until we join the room to get the initial chat history.
|
|
if !self.hasReceiveInitialHistory, !self.hasRequestedInitialHistory, self.room.lastReadMessage > 0 {
|
|
self.hasRequestedInitialHistory = true
|
|
self.chatController.getInitialChatHistory()
|
|
}
|
|
|
|
if !self.offlineMode {
|
|
NCRoomsManager.sharedInstance().joinRoom(self.room.token, forCall: false)
|
|
}
|
|
|
|
// Check if there are summary tasks still running, but not yet finished
|
|
if !AiSummaryController.shared.getSummaryTaskIds(forRoomInternalId: self.room.internalId).isEmpty {
|
|
self.showGeneratingSummaryNotification()
|
|
self.scheduleSummaryTaskCheck()
|
|
}
|
|
}
|
|
|
|
public override func viewWillDisappear(_ animated: Bool) {
|
|
super.viewWillDisappear(animated)
|
|
|
|
self.saveLastReadMessage()
|
|
self.stopVoiceMessagePlayer()
|
|
}
|
|
|
|
public override func viewDidDisappear(_ animated: Bool) {
|
|
super.viewDidDisappear(animated)
|
|
|
|
if self.isMovingFromParent {
|
|
self.leaveChat()
|
|
}
|
|
|
|
self.videoCallButton.hideIndicator()
|
|
self.voiceCallButton.hideIndicator()
|
|
}
|
|
|
|
required init?(coder decoder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
// MARK: - App lifecycle
|
|
|
|
func appDidBecomeActive(notification: Notification) {
|
|
// Don't handle this event if the view is not loaded yet.
|
|
// Otherwise we try to join the room and receive new messages while
|
|
// viewDidLoad wasn't called, resulting in uninitialized dictionaries and crashes
|
|
if !self.isViewLoaded {
|
|
return
|
|
}
|
|
|
|
// If we stopped the chat, we don't want to resume it here
|
|
if self.hasStopped {
|
|
return
|
|
}
|
|
|
|
// Check if new messages were added while the app was inactive (eg. via background-refresh)
|
|
self.checkForNewStoredMessages()
|
|
|
|
if !self.offlineMode {
|
|
NCRoomsManager.sharedInstance().joinRoom(self.room.token, forCall: false)
|
|
}
|
|
|
|
self.startObservingExpiredMessages()
|
|
}
|
|
|
|
func appWillResignActive(notification: Notification) {
|
|
// If we stopped the chat, we don't want to change anything here
|
|
if self.hasStopped {
|
|
return
|
|
}
|
|
|
|
self.startReceivingMessagesAfterJoin = true
|
|
self.removeUnreadMessagesSeparator()
|
|
self.savePendingMessage()
|
|
self.chatController.stop()
|
|
self.messageExpirationTimer?.invalidate()
|
|
self.stopTyping(force: false)
|
|
NCRoomsManager.sharedInstance().leaveChat(inRoom: self.room.token)
|
|
}
|
|
|
|
func connectionStateHasChanged(notification: Notification) {
|
|
guard let rawConnectionState = notification.userInfo?["connectionState"] as? Int, let connectionState = ConnectionState(rawValue: rawConnectionState) else {
|
|
return
|
|
}
|
|
|
|
switch connectionState {
|
|
case .connected:
|
|
if offlineMode {
|
|
offlineMode = false
|
|
startReceivingMessagesAfterJoin = true
|
|
self.removeOfflineFooterView()
|
|
NCRoomsManager.sharedInstance().joinRoom(self.room.token, forCall: false)
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
func maintenanceModeActive(notification: Notification) {
|
|
self.setOfflineMode()
|
|
}
|
|
|
|
// MARK: - User Interface
|
|
|
|
func disableRoomControls() {
|
|
self.titleView?.isUserInteractionEnabled = false
|
|
|
|
self.videoCallButton.hideIndicator()
|
|
self.videoCallButton.isEnabled = false
|
|
self.voiceCallButton.hideIndicator()
|
|
self.voiceCallButton.isEnabled = false
|
|
|
|
self.rightButton.isEnabled = false
|
|
self.leftButton.isEnabled = false
|
|
}
|
|
|
|
func checkRoomControlsAvailability() {
|
|
if hasJoinedRoom, !offlineMode {
|
|
// Enable room info and call buttons when we joined a room
|
|
self.titleView?.isUserInteractionEnabled = true
|
|
self.videoCallButton.isEnabled = true
|
|
self.voiceCallButton.isEnabled = true
|
|
}
|
|
|
|
// Files/objects can only be send when we're not offline
|
|
self.leftButton.isEnabled = !offlineMode
|
|
|
|
// Always allow to start writing a message, even if we didn't join the room (yet)
|
|
self.rightButton.isEnabled = self.canPressRightButton()
|
|
self.textInputbar.isUserInteractionEnabled = true
|
|
|
|
if !room.userCanStartCall, !room.hasCall {
|
|
// Disable call buttons
|
|
self.videoCallButton.isEnabled = false
|
|
self.voiceCallButton.isEnabled = false
|
|
}
|
|
|
|
if room.readOnlyState == .readOnly || self.shouldPresentLobbyView() {
|
|
// Hide text input
|
|
self.setTextInputbarHidden(true, animated: self.isVisible)
|
|
|
|
// Disable call buttons
|
|
self.videoCallButton.isEnabled = false
|
|
self.voiceCallButton.isEnabled = false
|
|
} else if NCDatabaseManager.sharedInstance().roomHasTalkCapability(kCapabilityChatPermission, for: room), !room.permissions.contains(.chat) {
|
|
// Hide text input
|
|
self.setTextInputbarHidden(true, animated: isVisible)
|
|
} else if self.isTextInputbarHidden {
|
|
// Show text input if it was hidden in a previous state
|
|
self.setTextInputbarHidden(false, animated: isVisible)
|
|
|
|
if self.tableView?.slk_isAtBottom ?? false {
|
|
self.tableView?.slk_scrollToBottom(animated: true)
|
|
}
|
|
|
|
// Make sure the textinput has the correct height
|
|
self.setChatMessage(self.textInputbar.textView.text)
|
|
}
|
|
|
|
if self.presentedInCall {
|
|
// Create a close button and remove the call buttons
|
|
let barButtonItem = UIBarButtonItem(title: nil, style: .plain, target: nil, action: nil)
|
|
barButtonItem.primaryAction = UIAction(title: NSLocalizedString("Close", comment: ""), handler: { _ in
|
|
NCRoomsManager.sharedInstance().callViewController?.toggleChatView()
|
|
})
|
|
self.navigationItem.rightBarButtonItems = [barButtonItem]
|
|
}
|
|
}
|
|
|
|
func checkLobbyState() {
|
|
if self.shouldPresentLobbyView() {
|
|
self.hasPresentedLobby = true
|
|
|
|
var placeholderText = NSLocalizedString("You are currently waiting in the lobby", comment: "")
|
|
|
|
// Lobby timer
|
|
if self.room.lobbyTimer > 0 {
|
|
let date = Date(timeIntervalSince1970: TimeInterval(self.room.lobbyTimer))
|
|
let meetingStart = NCUtils.readableDateTime(fromDate: date)
|
|
let meetingStartPlaceholder = NSLocalizedString("This meeting is scheduled for", comment: "The meeting start time will be displayed after this text e.g (This meeting is scheduled for tomorrow at 10:00)")
|
|
placeholderText += "\n\n\(meetingStartPlaceholder)\n\(meetingStart)"
|
|
}
|
|
|
|
// Room description
|
|
if NCDatabaseManager.sharedInstance().roomHasTalkCapability(kCapabilityRoomDescription, for: room), !self.room.roomDescription.isEmpty {
|
|
placeholderText += "\n\n" + self.room.roomDescription
|
|
}
|
|
|
|
// Only set it when text changes to avoid flickering in links
|
|
if chatBackgroundView.placeholderTextView.text != placeholderText {
|
|
chatBackgroundView.placeholderTextView.text = placeholderText
|
|
}
|
|
|
|
self.chatBackgroundView.setImage(UIImage(named: "lobby-placeholder"))
|
|
self.chatBackgroundView.placeholderView.isHidden = false
|
|
self.chatBackgroundView.loadingView.stopAnimating()
|
|
self.chatBackgroundView.loadingView.isHidden = true
|
|
|
|
// Clear current chat since chat history will be retrieved when lobby is disabled
|
|
self.cleanChat()
|
|
} else {
|
|
self.chatBackgroundView.setImage(UIImage(named: "chat-placeholder"))
|
|
self.chatBackgroundView.placeholderTextView.text = NSLocalizedString("No messages yet, start the conversation!", comment: "")
|
|
self.chatBackgroundView.placeholderView.isHidden = true
|
|
self.chatBackgroundView.loadingView.startAnimating()
|
|
self.chatBackgroundView.loadingView.isHidden = false
|
|
|
|
// Stop checking lobby flag
|
|
self.lobbyCheckTimer?.invalidate()
|
|
|
|
// Retrieve initial chat history if lobby was enabled and we didn't retrieve it before
|
|
if !hasReceiveInitialHistory, !hasRequestedInitialHistory, hasPresentedLobby {
|
|
self.hasRequestedInitialHistory = true
|
|
self.chatController.getInitialChatHistory()
|
|
}
|
|
|
|
self.hasPresentedLobby = false
|
|
}
|
|
}
|
|
|
|
func setOfflineFooterView() {
|
|
let isAtBottom = self.shouldScrollOnNewMessages()
|
|
|
|
let footerLabel = UILabel(frame: .init(x: 0, y: 0, width: 350, height: 24))
|
|
footerLabel.textAlignment = .center
|
|
footerLabel.textColor = .label
|
|
footerLabel.font = .systemFont(ofSize: 12)
|
|
footerLabel.backgroundColor = .clear
|
|
footerLabel.text = NSLocalizedString("Offline, only showing downloaded messages", comment: "")
|
|
|
|
self.tableView?.tableFooterView = footerLabel
|
|
self.tableView?.tableFooterView?.backgroundColor = .secondarySystemBackground
|
|
|
|
if isAtBottom {
|
|
self.tableView?.slk_scrollToBottom(animated: true)
|
|
}
|
|
}
|
|
|
|
func removeOfflineFooterView() {
|
|
DispatchQueue.main.async {
|
|
self.tableView?.tableFooterView?.removeFromSuperview()
|
|
self.tableView?.tableFooterView = nil
|
|
|
|
// Scrolling after removing the tableFooterView won't scroll all the way to the bottom therefore just keep the current position
|
|
// And don't try to call scrollToBottom
|
|
}
|
|
}
|
|
|
|
func setOfflineMode() {
|
|
self.offlineMode = true
|
|
self.setOfflineFooterView()
|
|
self.chatController.stopReceivingNewChatMessages()
|
|
self.disableRoomControls()
|
|
self.checkRoomControlsAvailability()
|
|
}
|
|
|
|
let outOfOfficeView: OutOfOfficeView? = nil
|
|
|
|
func checkOutOfOfficeAbsence() {
|
|
// Only check once, and only for 1:1 on DND right now
|
|
guard self.hasCheckedOutOfOfficeStatus == false,
|
|
self.room.type == .oneToOne,
|
|
self.room.status == kUserStatusDND,
|
|
let serverCapabilities = NCDatabaseManager.sharedInstance().serverCapabilities(forAccountId: self.room.accountId),
|
|
serverCapabilities.absenceSupported
|
|
else { return }
|
|
|
|
self.hasCheckedOutOfOfficeStatus = true
|
|
|
|
NCAPIController.sharedInstance().getCurrentUserAbsence(forAccountId: self.room.accountId, forUserId: self.room.name) { absenceData in
|
|
guard let absenceData else { return }
|
|
|
|
let oooView = OutOfOfficeView()
|
|
oooView.setupAbsence(withData: absenceData, inRoom: self.room)
|
|
oooView.alpha = 0
|
|
|
|
self.view.addSubview(oooView)
|
|
|
|
NSLayoutConstraint.activate([
|
|
oooView.leadingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.leadingAnchor),
|
|
oooView.trailingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.trailingAnchor),
|
|
oooView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor)
|
|
])
|
|
|
|
UIView.animate(withDuration: 0.3, delay: 0, options: [.curveEaseInOut]) {
|
|
oooView.alpha = 1.0
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Message expiration
|
|
|
|
func startObservingExpiredMessages() {
|
|
self.messageExpirationTimer?.invalidate()
|
|
self.removeExpiredMessages()
|
|
self.messageExpirationTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true, block: { [weak self] _ in
|
|
self?.removeExpiredMessages()
|
|
})
|
|
}
|
|
|
|
func removeExpiredMessages() {
|
|
DispatchQueue.main.async {
|
|
let currentTimestamp = Int(Date().timeIntervalSince1970)
|
|
|
|
// Iterate backwards in case we need to delete multiple sections in one go
|
|
for sectionIndex in self.dateSections.indices.reversed() {
|
|
let section = self.dateSections[sectionIndex]
|
|
|
|
guard let messages = self.messages[section] else { continue }
|
|
|
|
let deleteMessages = messages.filter { message in
|
|
return message.expirationTimestamp > 0 && message.expirationTimestamp <= currentTimestamp
|
|
}
|
|
|
|
if !deleteMessages.isEmpty {
|
|
self.tableView?.beginUpdates()
|
|
|
|
let filteredMessages = messages.filter { !deleteMessages.contains($0) }
|
|
self.messages[section] = filteredMessages
|
|
|
|
if !filteredMessages.isEmpty {
|
|
self.tableView?.reloadSections(IndexSet(integer: sectionIndex), with: .top)
|
|
} else {
|
|
self.messages.removeValue(forKey: section)
|
|
self.sortDateSections()
|
|
self.tableView?.deleteSections(IndexSet(integer: sectionIndex), with: .top)
|
|
}
|
|
|
|
self.tableView?.endUpdates()
|
|
}
|
|
}
|
|
|
|
self.chatController.removeExpiredMessages()
|
|
}
|
|
}
|
|
|
|
// MARK: - Utils
|
|
|
|
func presentJoinError(_ subtitle: String) {
|
|
NotificationPresenter.shared().present(title: NSLocalizedString("Could not join conversation", comment: ""), subtitle: subtitle, includedStyle: .warning)
|
|
NotificationPresenter.shared().dismiss(afterDelay: 8.0)
|
|
}
|
|
|
|
// MARK: - Action methods
|
|
|
|
override func sendChatMessage(message: String, withParentMessage parentMessage: NCChatMessage?, messageParameters: String, silently: Bool) {
|
|
// Create temporary message
|
|
guard let temporaryMessage = self.createTemporaryMessage(message: message, replyTo: parentMessage, messageParameters: messageParameters, silently: silently, isVoiceMessage: false) else { return }
|
|
|
|
if NCDatabaseManager.sharedInstance().roomHasTalkCapability(kCapabilityChatReferenceId, for: room) {
|
|
self.appendTemporaryMessage(temporaryMessage: temporaryMessage)
|
|
}
|
|
|
|
// Send message
|
|
self.chatController.send(temporaryMessage)
|
|
}
|
|
|
|
public override func canPressRightButton() -> Bool {
|
|
let canPress = super.canPressRightButton()
|
|
|
|
if self.textInputbar.isEditing {
|
|
// When we're editing, we can use the default implementation, as we don't want to save an empty message
|
|
return canPress
|
|
}
|
|
|
|
// If in offline mode, we don't want to show the voice button
|
|
if !offlineMode, !canPress, !presentedInCall,
|
|
NCDatabaseManager.sharedInstance().roomHasTalkCapability(kCapabilityVoiceMessage, for: room),
|
|
!room.isFederated {
|
|
|
|
self.showVoiceMessageRecordButton()
|
|
return true
|
|
}
|
|
|
|
self.showSendMessageButton()
|
|
|
|
return canPress
|
|
}
|
|
|
|
// MARK: - Voice message player
|
|
// Override the default implementation to don't hijack the audio session when presented in a call
|
|
|
|
override func playVoiceMessagePlayer() {
|
|
if !self.presentedInCall {
|
|
self.setSpeakerAudioSession()
|
|
self.enableProximitySensor()
|
|
}
|
|
|
|
self.startVoiceMessagePlayerTimer()
|
|
self.voiceMessagesPlayer?.play()
|
|
}
|
|
|
|
override func pauseVoiceMessagePlayer() {
|
|
if !self.presentedInCall {
|
|
self.disableProximitySensor()
|
|
}
|
|
|
|
self.stopVoiceMessagePlayerTimer()
|
|
self.voiceMessagesPlayer?.pause()
|
|
self.checkVisibleCellAudioPlayers()
|
|
}
|
|
|
|
override func stopVoiceMessagePlayer() {
|
|
if !self.presentedInCall {
|
|
self.disableProximitySensor()
|
|
}
|
|
|
|
self.stopVoiceMessagePlayerTimer()
|
|
self.voiceMessagesPlayer?.stop()
|
|
}
|
|
|
|
override func sensorStateChange(notification: Notification) {
|
|
if self.presentedInCall {
|
|
return
|
|
}
|
|
|
|
if UIDevice.current.proximityState {
|
|
self.setVoiceChatAudioSession()
|
|
} else {
|
|
self.pauseVoiceMessagePlayer()
|
|
self.setSpeakerAudioSession()
|
|
self.disableProximitySensor()
|
|
}
|
|
}
|
|
|
|
// MARK: - UIScrollViewDelegate methods
|
|
|
|
public override func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
|
super.scrollViewDidScroll(scrollView)
|
|
|
|
guard scrollView == self.tableView,
|
|
scrollView.contentOffset.y < 0,
|
|
self.couldRetrieveHistory()
|
|
else { return }
|
|
|
|
if let firstMessage = self.getFirstRealMessage()?.message,
|
|
self.chatController.hasHistory(fromMessageId: firstMessage.messageId) {
|
|
|
|
self.retrievingHistory = true
|
|
self.showLoadingHistoryView()
|
|
|
|
if self.offlineMode {
|
|
self.chatController.getHistoryBatchOffline(fromMessagesId: firstMessage.messageId)
|
|
} else {
|
|
self.chatController.getHistoryBatch(fromMessagesId: firstMessage.messageId)
|
|
}
|
|
}
|
|
}
|
|
|
|
public func stopChat() {
|
|
self.hasStopped = true
|
|
self.chatController.stop()
|
|
self.cleanChat()
|
|
}
|
|
|
|
public func resumeChat() {
|
|
self.hasStopped = false
|
|
|
|
if !self.hasReceiveInitialHistory, !self.hasRequestedInitialHistory {
|
|
self.hasRequestedInitialHistory = true
|
|
self.chatController.getInitialChatHistory()
|
|
}
|
|
}
|
|
|
|
public func leaveChat() {
|
|
self.hasStopped = true
|
|
self.lobbyCheckTimer?.invalidate()
|
|
self.messageExpirationTimer?.invalidate()
|
|
self.generateSummaryTimer?.invalidate()
|
|
self.chatController.stop()
|
|
|
|
// Dismiss possible notifications
|
|
// swiftlint:disable:next notification_center_detachment
|
|
NotificationCenter.default.removeObserver(self)
|
|
|
|
// In case we're typing when we leave the chat, make sure we notify everyone
|
|
// The 'stopTyping' method makes sure to only send signaling messages when we were typing before
|
|
self.stopTyping(force: false)
|
|
|
|
// If this chat view controller is for the same room as the one owned by the rooms manager
|
|
// then we should not try to leave the chat. Since we will leave the chat when the
|
|
// chat view controller owned by rooms manager moves from parent view controller.
|
|
if NCRoomsManager.sharedInstance().chatViewController?.room.token == self.room.token,
|
|
NCRoomsManager.sharedInstance().chatViewController !== self {
|
|
return
|
|
}
|
|
|
|
NCRoomsManager.sharedInstance().leaveChat(inRoom: self.room.token)
|
|
|
|
// Remove chat view controller pointer if this chat is owned by rooms manager
|
|
// and the chat view is moving from parent view controller
|
|
if NCRoomsManager.sharedInstance().chatViewController === self {
|
|
NCRoomsManager.sharedInstance().chatViewController = nil
|
|
}
|
|
}
|
|
|
|
public override func cleanChat() {
|
|
super.cleanChat()
|
|
|
|
self.hasReceiveInitialHistory = false
|
|
self.hasRequestedInitialHistory = false
|
|
self.chatController.hasReceivedMessagesFromServer = false
|
|
}
|
|
|
|
func saveLastReadMessage() {
|
|
NCRoomsManager.sharedInstance().updateLastReadMessage(self.lastReadMessage, for: self.room)
|
|
}
|
|
|
|
// MARK: - Room Manager notifications
|
|
|
|
func didUpdateRoom(notification: Notification) {
|
|
guard let room = notification.userInfo?["room"] as? NCRoom else { return }
|
|
|
|
if room.token != self.room.token {
|
|
return
|
|
}
|
|
|
|
self.room = room
|
|
self.setTitleView()
|
|
|
|
if !self.hasStopped {
|
|
self.checkLobbyState()
|
|
self.checkRoomControlsAvailability()
|
|
}
|
|
}
|
|
|
|
func didJoinRoom(notification: Notification) {
|
|
guard let token = notification.userInfo?["token"] as? String else { return }
|
|
|
|
if token != self.room.token {
|
|
return
|
|
}
|
|
|
|
if self.isVisible,
|
|
notification.userInfo?["error"] != nil,
|
|
let errorReason = notification.userInfo?["errorReason"] as? String {
|
|
|
|
self.setOfflineMode()
|
|
self.presentJoinError(errorReason)
|
|
|
|
if let isBanned = notification.userInfo?["isBanned"] as? Bool, isBanned {
|
|
// Usually we remove all notifications when the view disappears, but in this case, we want to keep it
|
|
self.dismissNotificationsOnViewWillDisappear = false
|
|
|
|
// We are not allowed to join this conversation -> Move back to the conversation list
|
|
NCUserInterfaceController.sharedInstance().presentConversationsList()
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if let room = notification.userInfo?["room"] as? NCRoom, room.token == self.room.token {
|
|
self.room = room
|
|
}
|
|
|
|
self.hasJoinedRoom = true
|
|
self.checkRoomControlsAvailability()
|
|
|
|
if self.hasStopped {
|
|
return
|
|
}
|
|
|
|
if self.startReceivingMessagesAfterJoin, self.hasReceiveInitialHistory {
|
|
self.startReceivingMessagesAfterJoin = false
|
|
self.chatController.startReceivingNewChatMessages()
|
|
} else if !self.hasReceiveInitialHistory, !self.hasRequestedInitialHistory {
|
|
self.hasRequestedInitialHistory = true
|
|
self.chatController.getInitialChatHistory()
|
|
}
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 4) { [capturedToken = self.room.token] in
|
|
// After we joined a room, check if there are offline messages for this particular room which need to be send
|
|
NCRoomsManager.sharedInstance().resendOfflineMessages(forToken: capturedToken, withCompletionBlock: nil)
|
|
}
|
|
}
|
|
|
|
func didLeaveRoom(notification: Notification) {
|
|
guard let token = notification.userInfo?["token"] as? String else { return }
|
|
|
|
if token != self.room.token {
|
|
return
|
|
}
|
|
|
|
if notification.userInfo?["error"] != nil {
|
|
// In case an error occurred when leaving the room, we assume we are still joined
|
|
return
|
|
}
|
|
|
|
self.hasJoinedRoom = false
|
|
self.disableRoomControls()
|
|
self.checkRoomControlsAvailability()
|
|
}
|
|
|
|
// MARK: - CallKit Manager notifications
|
|
|
|
func didFailRequestingCallTransaction(notification: Notification) {
|
|
guard let token = notification.userInfo?["roomToken"] as? String else { return }
|
|
|
|
if token != self.room.token {
|
|
return
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
self.videoCallButton.hideIndicator()
|
|
self.voiceCallButton.hideIndicator()
|
|
}
|
|
}
|
|
|
|
// MARK: - Chat Controller notifications
|
|
|
|
// swiftlint:disable:next cyclomatic_complexity
|
|
func didReceiveInitialChatHistory(notification: Notification) {
|
|
DispatchQueue.main.async {
|
|
if notification.object as? NCChatController != self.chatController {
|
|
return
|
|
}
|
|
|
|
if self.shouldPresentLobbyView() {
|
|
self.hasRequestedInitialHistory = false
|
|
self.startObservingRoomLobbyFlag()
|
|
|
|
return
|
|
}
|
|
|
|
if let messages = notification.userInfo?["messages"] as? [NCChatMessage], !messages.isEmpty {
|
|
|
|
var indexPathUnreadMessageSeparator: IndexPath?
|
|
let lastMessage = messages.reversed().first(where: { !$0.isUpdateMessage })
|
|
|
|
self.appendMessages(messages: messages)
|
|
|
|
if let lastMessage, lastMessage.messageId > self.lastReadMessage {
|
|
// Iterate backwards to find the correct location for the unread message separator
|
|
for sectionIndex in self.dateSections.indices.reversed() {
|
|
let dateSection: Date = self.dateSections[sectionIndex]
|
|
|
|
guard var messages = self.messages[dateSection] else { continue }
|
|
|
|
for messageIndex in messages.indices.reversed() {
|
|
let message = messages[messageIndex]
|
|
|
|
if message.messageId > self.lastReadMessage {
|
|
continue
|
|
}
|
|
|
|
// Store the messageId separately from self.lastReadMessage as that might change during a room update
|
|
self.generateSummaryFromMessageId = message.messageId
|
|
messages.insert(self.unreadMessagesSeparator, at: messageIndex + 1)
|
|
self.messages[dateSection] = messages
|
|
indexPathUnreadMessageSeparator = IndexPath(row: messageIndex + 1, section: sectionIndex)
|
|
|
|
break
|
|
}
|
|
|
|
if indexPathUnreadMessageSeparator != nil {
|
|
break
|
|
}
|
|
}
|
|
|
|
self.lastReadMessage = lastMessage.messageId
|
|
}
|
|
|
|
let storedTemporaryMessages = self.chatController.getTemporaryMessages()
|
|
|
|
if !storedTemporaryMessages.isEmpty {
|
|
self.insertMessages(messages: storedTemporaryMessages)
|
|
|
|
if indexPathUnreadMessageSeparator != nil {
|
|
// It is possible that temporary messages are added which add new sections
|
|
// In this case the indexPath of the unreadMessageSeparator would be invalid and could lead to a crash
|
|
// Therefore we need to make sure we got the correct indexPath here
|
|
indexPathUnreadMessageSeparator = self.indexPathForUnreadMessageSeparator()
|
|
}
|
|
}
|
|
|
|
self.tableView?.reloadData()
|
|
|
|
if let indexPathUnreadMessageSeparator {
|
|
self.tableView?.scrollToRow(at: indexPathUnreadMessageSeparator, at: .middle, animated: false)
|
|
} else {
|
|
self.tableView?.slk_scrollToBottom(animated: false)
|
|
}
|
|
|
|
self.updateToolbar(animated: false)
|
|
} else {
|
|
self.chatBackgroundView.placeholderView.isHidden = false
|
|
}
|
|
|
|
self.hasReceiveInitialHistory = true
|
|
|
|
if notification.userInfo?["error"] == nil {
|
|
self.chatController.startReceivingNewChatMessages()
|
|
} else {
|
|
self.offlineMode = true
|
|
self.chatController.getInitialChatHistoryForOfflineMode()
|
|
}
|
|
}
|
|
}
|
|
|
|
func didReceiveInitialChatHistoryOffline(notification: Notification) {
|
|
DispatchQueue.main.async {
|
|
if notification.object as? NCChatController != self.chatController {
|
|
return
|
|
}
|
|
|
|
if let messages = notification.userInfo?["messages"] as? [NCChatMessage], !messages.isEmpty {
|
|
self.appendMessages(messages: messages)
|
|
self.setOfflineFooterView()
|
|
self.tableView?.reloadData()
|
|
self.tableView?.slk_scrollToBottom(animated: false)
|
|
self.updateToolbar(animated: false)
|
|
} else {
|
|
self.chatBackgroundView.placeholderView.isHidden = false
|
|
}
|
|
|
|
let storedTemporaryMessages = self.chatController.getTemporaryMessages()
|
|
|
|
if !storedTemporaryMessages.isEmpty {
|
|
|
|
self.insertMessages(messages: storedTemporaryMessages)
|
|
self.tableView?.reloadData()
|
|
self.tableView?.slk_scrollToBottom(animated: false)
|
|
self.updateToolbar(animated: false)
|
|
}
|
|
}
|
|
}
|
|
|
|
func didReceiveChatHistory(notification: Notification) {
|
|
DispatchQueue.main.async {
|
|
if notification.object as? NCChatController != self.chatController {
|
|
return
|
|
}
|
|
|
|
if let messages = notification.userInfo?["messages"] as? [NCChatMessage], !messages.isEmpty {
|
|
|
|
let shouldAddBlockSeparator = notification.userInfo?["shouldAddBlockSeparator"] as? Bool ?? false
|
|
|
|
if let lastHistoryMessageIP = self.prependMessages(historyMessages: messages, addingBlockSeparator: shouldAddBlockSeparator),
|
|
let tableView = self.tableView {
|
|
|
|
self.tableView?.reloadData()
|
|
|
|
if NCUtils.isValid(indexPath: lastHistoryMessageIP, forTableView: tableView) {
|
|
self.tableView?.scrollToRow(at: lastHistoryMessageIP, at: .top, animated: false)
|
|
}
|
|
}
|
|
}
|
|
|
|
if notification.userInfo?["noMoreStoredHistory"] as? Bool == true {
|
|
self.hasStoredHistory = false
|
|
}
|
|
|
|
self.retrievingHistory = false
|
|
self.hideLoadingHistoryView()
|
|
}
|
|
}
|
|
|
|
// swiftlint:disable:next cyclomatic_complexity
|
|
func didReceiveChatMessages(notification: Notification) {
|
|
// If we receive messages in the background, we should make sure that our update here completely run
|
|
let bgTask = BGTaskHelper.startBackgroundTask(withName: "didReceiveChatMessages")
|
|
|
|
DispatchQueue.main.async {
|
|
if notification.object as? NCChatController != self.chatController || notification.userInfo?["error"] != nil {
|
|
return
|
|
}
|
|
|
|
let firstNewMessagesAfterHistory = notification.userInfo?["firstNewMessagesAfterHistory"] as? Bool ?? false
|
|
|
|
if let messages = notification.userInfo?["messages"] as? [NCChatMessage], let tableView = self.tableView, !messages.isEmpty {
|
|
// Detect if we should scroll to new messages before we issue a reloadData
|
|
// Otherwise longer messages will prevent scrolling
|
|
let shouldScrollOnNewMessages = self.shouldScrollOnNewMessages()
|
|
let newMessagesContainVisibleMessages = messages.containsVisibleMessages()
|
|
|
|
// Use a Set here so we don't have to deal with duplicates
|
|
var insertIndexPaths: Set<IndexPath> = []
|
|
var insertSections: IndexSet = []
|
|
var reloadIndexPaths: Set<IndexPath> = []
|
|
|
|
var addedUnreadMessageSeparator = false
|
|
|
|
// Check if unread messages separator should be added (only if it's not already shown)
|
|
if firstNewMessagesAfterHistory, let lastRealMessage = self.getLastRealMessage(), self.indexPathForUnreadMessageSeparator() == nil, newMessagesContainVisibleMessages,
|
|
let lastDateSection = self.dateSections.last, var messagesBeforeUpdate = self.messages[lastDateSection] {
|
|
|
|
// Store the messageId separately from self.lastReadMessage as that might change during a room update
|
|
self.generateSummaryFromMessageId = lastRealMessage.message.messageId
|
|
messagesBeforeUpdate.append(self.unreadMessagesSeparator)
|
|
self.messages[lastDateSection] = messagesBeforeUpdate
|
|
insertIndexPaths.insert(IndexPath(row: messagesBeforeUpdate.count - 1, section: self.dateSections.count - 1))
|
|
addedUnreadMessageSeparator = true
|
|
}
|
|
|
|
self.appendMessages(messages: messages)
|
|
|
|
for newMessage in messages {
|
|
// Update messages might trigger an reload of another cell, but are not part of the tableView itself
|
|
if newMessage.isUpdateMessage {
|
|
if let parentMessage = newMessage.parent, let parentPath = self.indexPath(for: parentMessage) {
|
|
if parentPath.section < tableView.numberOfSections, parentPath.row < tableView.numberOfRows(inSection: parentPath.section) {
|
|
// We received an update message to a message which is already part of our current data, therefore we need to reload it
|
|
reloadIndexPaths.insert(parentPath)
|
|
}
|
|
}
|
|
|
|
continue
|
|
}
|
|
|
|
// If we don't get an indexPath here, something is wrong with our appendMessages function
|
|
let indexPath = self.indexPath(for: newMessage)!
|
|
|
|
if indexPath.section >= tableView.numberOfSections {
|
|
// New section -> insert the section
|
|
insertSections.insert(indexPath.section)
|
|
}
|
|
|
|
if indexPath.section < tableView.numberOfSections, indexPath.row < tableView.numberOfRows(inSection: indexPath.section) {
|
|
// This is a already known indexPath, so we want to reload the cell
|
|
reloadIndexPaths.insert(indexPath)
|
|
} else {
|
|
// New indexPath -> insert it
|
|
insertIndexPaths.insert(indexPath)
|
|
}
|
|
|
|
if let collapsedByMessage = newMessage.collapsedBy, let collapsedPath = self.indexPath(for: collapsedByMessage) {
|
|
if collapsedPath.section < tableView.numberOfSections, collapsedPath.row < tableView.numberOfRows(inSection: collapsedPath.section) {
|
|
// The current message is collapsed, so we need to make sure that the collapsedBy message is reloaded
|
|
reloadIndexPaths.insert(collapsedPath)
|
|
}
|
|
}
|
|
}
|
|
|
|
tableView.performBatchUpdates {
|
|
if !insertSections.isEmpty {
|
|
tableView.insertSections(insertSections, with: .automatic)
|
|
}
|
|
|
|
if !insertIndexPaths.isEmpty {
|
|
tableView.insertRows(at: Array(insertIndexPaths), with: .automatic)
|
|
}
|
|
|
|
if !reloadIndexPaths.isEmpty {
|
|
tableView.reloadRows(at: Array(reloadIndexPaths), with: .none)
|
|
}
|
|
|
|
} completion: { _ in
|
|
// Remove unread messages separator when user writes a message
|
|
if messages.containsMessage(forUserId: self.account.userId) {
|
|
self.removeUnreadMessagesSeparator()
|
|
}
|
|
|
|
// Only scroll to unread message separator if we added it while processing the received messages
|
|
// Otherwise we would scroll whenever a unread message separator is available
|
|
if addedUnreadMessageSeparator, let indexPathUnreadMessageSeparator = self.indexPathForUnreadMessageSeparator() {
|
|
tableView.scrollToRow(at: indexPathUnreadMessageSeparator, at: .middle, animated: true)
|
|
} else if (shouldScrollOnNewMessages || messages.containsMessage(forUserId: self.account.userId)), let lastIndexPath = self.getLastRealMessage()?.indexPath {
|
|
tableView.scrollToRow(at: lastIndexPath, at: .none, animated: true)
|
|
} else if self.firstUnreadMessage == nil, newMessagesContainVisibleMessages, let firstNewMessage = messages.first {
|
|
// This check is needed since several calls to receiveMessages API might be needed
|
|
// (if the number of unread messages is bigger than the "limit" in receiveMessages request)
|
|
// to receive all the unread messages.
|
|
if firstNewMessage.timestamp >= Int(self.chatViewPresentedTimestamp) {
|
|
self.showNewMessagesView(until: firstNewMessage)
|
|
}
|
|
}
|
|
|
|
// Set last received message as last read message
|
|
if let lastReceivedMessage = messages.last {
|
|
self.lastReadMessage = lastReceivedMessage.messageId
|
|
}
|
|
}
|
|
|
|
if firstNewMessagesAfterHistory {
|
|
self.chatBackgroundView.loadingView.stopAnimating()
|
|
self.chatBackgroundView.loadingView.isHidden = true
|
|
}
|
|
|
|
if self.highlightMessageId > 0, let indexPath = self.indexPathAndMessage(forMessageId: self.highlightMessageId)?.indexPath {
|
|
self.highlightMessage(at: indexPath, with: .middle)
|
|
self.highlightMessageId = 0
|
|
}
|
|
}
|
|
|
|
bgTask.stopBackgroundTask()
|
|
}
|
|
}
|
|
|
|
func didSendChatMessage(notification: Notification) {
|
|
DispatchQueue.main.async {
|
|
if notification.object as? NCChatController != self.chatController {
|
|
return
|
|
}
|
|
|
|
if notification.userInfo?["error"] == nil {
|
|
return
|
|
}
|
|
|
|
guard let message = notification.userInfo?["message"] as? String else { return }
|
|
|
|
if let referenceId = notification.userInfo?["referenceId"] as? String {
|
|
// Got a referenceId -> update the corresponding message
|
|
let isOfflineMessage = notification.userInfo?["isOfflineMessage"] as? Bool ?? false
|
|
|
|
self.modifyMessageWith(referenceId: referenceId) { message in
|
|
message.sendingFailed = !isOfflineMessage
|
|
message.isOfflineMessage = isOfflineMessage
|
|
}
|
|
|
|
} else {
|
|
// No referenceId -> show generic error
|
|
self.textView.text = message
|
|
|
|
let alert = UIAlertController(title: NSLocalizedString("Could not send the message", comment: ""),
|
|
message: NSLocalizedString("An error occurred while sending the message", comment: ""),
|
|
preferredStyle: .alert)
|
|
|
|
alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .default))
|
|
NCUserInterfaceController.sharedInstance().presentAlertViewController(alert)
|
|
}
|
|
}
|
|
}
|
|
|
|
func didReceiveChatBlocked(notification: Notification) {
|
|
if notification.object as? NCChatController != self.chatController {
|
|
return
|
|
}
|
|
|
|
self.startObservingRoomLobbyFlag()
|
|
}
|
|
|
|
func didReceiveNewerCommonReadMessage(notification: Notification) {
|
|
if notification.object as? NCChatController != self.chatController {
|
|
return
|
|
}
|
|
|
|
guard let lastCommonReadMessage = notification.userInfo?["lastCommonReadMessage"] as? Int else { return }
|
|
|
|
if lastCommonReadMessage > self.room.lastCommonReadMessage {
|
|
self.room.lastCommonReadMessage = lastCommonReadMessage
|
|
}
|
|
|
|
self.checkLastCommonReadMessage()
|
|
}
|
|
|
|
func didReceiveCallStartedMessage(notification: Notification) {
|
|
if notification.object as? NCChatController != self.chatController {
|
|
return
|
|
}
|
|
|
|
self.room.hasCall = true
|
|
self.checkRoomControlsAvailability()
|
|
}
|
|
|
|
func didReceiveCallEndedMessage(notification: Notification) {
|
|
if notification.object as? NCChatController != self.chatController {
|
|
return
|
|
}
|
|
|
|
self.room.hasCall = false
|
|
self.checkRoomControlsAvailability()
|
|
}
|
|
|
|
func didReceiveUpdateMessage(notification: Notification) {
|
|
if notification.object as? NCChatController != self.chatController {
|
|
return
|
|
}
|
|
|
|
guard let message = notification.userInfo?["updateMessage"] as? NCChatMessage,
|
|
let updateMessage = message.parent
|
|
else { return }
|
|
|
|
self.updateMessage(withMessageId: updateMessage.messageId, updatedMessage: updateMessage)
|
|
}
|
|
|
|
func didReceiveHistoryCleared(notification: Notification) {
|
|
if notification.object as? NCChatController != self.chatController {
|
|
return
|
|
}
|
|
|
|
guard let message = notification.userInfo?["historyCleared"] as? NCChatMessage
|
|
else { return }
|
|
|
|
if self.chatController.hasOlderStoredMessagesThanMessageId(message.messageId) {
|
|
self.cleanChat()
|
|
self.chatController.clearHistoryAndResetChatController()
|
|
self.hasRequestedInitialHistory = false
|
|
self.chatController.getInitialChatHistory()
|
|
}
|
|
}
|
|
|
|
func didReceiveMessagesInBackground(notification: Notification) {
|
|
if notification.object as? NCChatController != self.chatController {
|
|
return
|
|
}
|
|
|
|
print("didReceiveMessagesInBackground")
|
|
self.checkForNewStoredMessages()
|
|
}
|
|
|
|
// MARK: - Database controller notifications
|
|
|
|
func didChangeRoomCapabilities(notification: Notification) {
|
|
guard let token = notification.userInfo?["roomToken"] as? String else { return }
|
|
|
|
if token != self.room.token {
|
|
return
|
|
}
|
|
|
|
self.tableView?.reloadData()
|
|
self.checkRoomControlsAvailability()
|
|
}
|
|
|
|
// MARK: - External signaling controller notifications
|
|
|
|
func didUpdateParticipants(notification: Notification) {
|
|
guard let token = notification.userInfo?["roomToken"] as? String else { return }
|
|
|
|
if token != self.room.token {
|
|
return
|
|
}
|
|
|
|
let serverSupportsConversationPermissions = NCDatabaseManager.sharedInstance().roomHasTalkCapability(kCapabilityConversationPermissions, for: room) ||
|
|
NCDatabaseManager.sharedInstance().roomHasTalkCapability(kCapabilityDirectMentionFlag, for: room)
|
|
|
|
guard serverSupportsConversationPermissions else { return }
|
|
|
|
// Retrieve the information about ourselves
|
|
guard let userDict = notification.userInfo?["users"] as? [[String: String]],
|
|
let appUserDict = userDict.first(where: { $0["userId"] == self.account.userId })
|
|
else { return }
|
|
|
|
// Check if we still have the same permissions
|
|
|
|
if let permissionsString = appUserDict["participantPermissions"],
|
|
let permissions = Int(permissionsString),
|
|
permissions != self.room.permissions.rawValue {
|
|
|
|
// Need to update the room from the api because otherwise "canStartCall" is not updated correctly
|
|
NCRoomsManager.sharedInstance().updateRoom(self.room.token, withCompletionBlock: nil)
|
|
}
|
|
}
|
|
|
|
func processTypingNotification(notification: Notification, startedTyping started: Bool) {
|
|
guard let token = notification.userInfo?["roomToken"] as? String,
|
|
let sessionId = notification.userInfo?["sessionId"] as? String,
|
|
token == self.room.token
|
|
else { return }
|
|
|
|
// Waiting for https://github.com/nextcloud/spreed/issues/9726 to receive the correct displayname for guests
|
|
let displayName = notification.userInfo?["displayName"] as? String ?? NSLocalizedString("Guest", comment: "")
|
|
|
|
// Don't show a typing indicator for ourselves or if typing indicator setting is disabled
|
|
// Workaround: TypingPrivacy should be checked locally, not from the remote server, use serverCapabilities for now
|
|
// TODO: Remove workaround for federated typing indicators.
|
|
guard let serverCapabilities = NCDatabaseManager.sharedInstance().serverCapabilities(forAccountId: self.room.accountId)
|
|
else { return }
|
|
|
|
let userId = notification.userInfo?["userId"] as? String
|
|
let isFederated = notification.userInfo?["isFederated"] as? Bool ?? false
|
|
|
|
// Since our own userId can exist on other servers, only suppress the notification if it's not federated
|
|
if (userId == self.account.userId && !isFederated) || serverCapabilities.typingPrivacy {
|
|
return
|
|
}
|
|
|
|
// For guests we use the sessionId as an identifier, for users we use the userId
|
|
var userIdentifier = sessionId
|
|
|
|
if let userId, !userId.isEmpty {
|
|
userIdentifier = userId
|
|
}
|
|
|
|
if started {
|
|
self.addTypingIndicator(withUserIdentifier: userIdentifier, andDisplayName: displayName)
|
|
} else {
|
|
self.removeTypingIndicator(withUserIdentifier: userIdentifier)
|
|
}
|
|
}
|
|
|
|
func didReceiveStartedTyping(notification: Notification) {
|
|
self.processTypingNotification(notification: notification, startedTyping: true)
|
|
}
|
|
|
|
func didReceiveStoppedTyping(notification: Notification) {
|
|
self.processTypingNotification(notification: notification, startedTyping: false)
|
|
}
|
|
|
|
func didReceiveParticipantJoin(notification: Notification) {
|
|
guard let token = notification.userInfo?["roomToken"] as? String,
|
|
let sessionId = notification.userInfo?["sessionId"] as? String,
|
|
token == self.room.token
|
|
else { return }
|
|
|
|
DispatchQueue.main.async {
|
|
if self.isTyping {
|
|
self.sendStartedTypingMessage(to: sessionId)
|
|
}
|
|
}
|
|
}
|
|
|
|
func didReceiveParticipantLeave(notification: Notification) {
|
|
guard let token = notification.userInfo?["roomToken"] as? String,
|
|
let sessionId = notification.userInfo?["sessionId"] as? String,
|
|
token == self.room.token
|
|
else { return }
|
|
|
|
// For guests we use the sessionId as an identifier, for users we use the userId
|
|
var userIdentifier = sessionId
|
|
|
|
if let userId = notification.userInfo?["userId"] as? String, !userId.isEmpty {
|
|
userIdentifier = userId
|
|
}
|
|
|
|
self.removeTypingIndicator(withUserIdentifier: userIdentifier)
|
|
}
|
|
|
|
// MARK: - Lobby functions
|
|
|
|
func startObservingRoomLobbyFlag() {
|
|
self.updateRoomInformation()
|
|
|
|
DispatchQueue.main.async {
|
|
self.lobbyCheckTimer?.invalidate()
|
|
self.lobbyCheckTimer = Timer.scheduledTimer(timeInterval: 5.0, target: self, selector: #selector(self.updateRoomInformation), userInfo: nil, repeats: true)
|
|
}
|
|
}
|
|
|
|
func updateRoomInformation() {
|
|
NCRoomsManager.sharedInstance().updateRoom(self.room.token, withCompletionBlock: nil)
|
|
}
|
|
|
|
func shouldPresentLobbyView() -> Bool {
|
|
let serverSupportsConversationPermissions =
|
|
NCDatabaseManager.sharedInstance().roomHasTalkCapability(kCapabilityConversationPermissions, for: room) ||
|
|
NCDatabaseManager.sharedInstance().roomHasTalkCapability(kCapabilityDirectMentionFlag, for: room)
|
|
|
|
if serverSupportsConversationPermissions, self.room.permissions.contains(.canIgnoreLobby) {
|
|
return false
|
|
}
|
|
|
|
return self.room.lobbyState == .moderatorsOnly && !self.room.canModerate
|
|
}
|
|
|
|
// MARK: - Chat functions
|
|
|
|
func couldRetrieveHistory() -> Bool {
|
|
return self.hasReceiveInitialHistory &&
|
|
!self.retrievingHistory &&
|
|
!self.dateSections.isEmpty
|
|
&& self.hasStoredHistory
|
|
}
|
|
|
|
func checkLastCommonReadMessage() {
|
|
DispatchQueue.main.async {
|
|
guard let tableView = self.tableView,
|
|
let indexPathsForVisibleRows = tableView.indexPathsForVisibleRows
|
|
else { return }
|
|
|
|
var reloadCells: [IndexPath] = []
|
|
|
|
for visibleIndexPath in indexPathsForVisibleRows {
|
|
if let message = self.message(for: visibleIndexPath),
|
|
message.messageId > 0,
|
|
message.messageId <= self.room.lastCommonReadMessage {
|
|
|
|
reloadCells.append(visibleIndexPath)
|
|
}
|
|
}
|
|
|
|
if !reloadCells.isEmpty {
|
|
self.tableView?.beginUpdates()
|
|
self.tableView?.reloadRows(at: reloadCells, with: .none)
|
|
self.tableView?.endUpdates()
|
|
}
|
|
}
|
|
}
|
|
|
|
func checkForNewStoredMessages() {
|
|
// Get the last "real" message. For temporary messages the messageId would be 0
|
|
// which would load all stored messages of the current conversation
|
|
if let lastMessage = self.getLastRealMessage()?.message {
|
|
self.chatController.checkForNewMessages(fromMessageId: lastMessage.messageId)
|
|
self.checkLastCommonReadMessage()
|
|
}
|
|
}
|
|
|
|
// MARK: - Editing support
|
|
|
|
public override func didCommitTextEditing(_ sender: Any) {
|
|
if let editingMessage {
|
|
let messageParametersJSONString = NCMessageParameter.messageParametersJSONString(from: self.mentionsDict) ?? ""
|
|
editingMessage.message = self.replaceMentionsDisplayNamesWithMentionsKeysInMessage(message: self.textView.text, parameters: messageParametersJSONString)
|
|
editingMessage.messageParametersJSONString = messageParametersJSONString
|
|
|
|
NCAPIController.sharedInstance().editChatMessage(inRoom: editingMessage.token, withMessageId: editingMessage.messageId, withMessage: editingMessage.sendingMessage, for: account) { messageDict, error, _ in
|
|
if error != nil {
|
|
NotificationPresenter.shared().present(text: NSLocalizedString("Error occurred while editing a message", comment: ""), dismissAfterDelay: 5.0, includedStyle: .error)
|
|
return
|
|
}
|
|
|
|
guard let messageDict,
|
|
let parent = messageDict["parent"] as? [AnyHashable: Any],
|
|
let updatedMessage = NCChatMessage(dictionary: parent, andAccountId: self.account.accountId)
|
|
else { return }
|
|
|
|
self.updateMessage(withMessageId: editingMessage.messageId, updatedMessage: updatedMessage)
|
|
}
|
|
}
|
|
|
|
super.didCommitTextEditing(sender)
|
|
}
|
|
|
|
// MARK: - ChatMessageTableViewCellDelegate delegate
|
|
|
|
override public func cellDidSelectedReaction(_ reaction: NCChatReaction!, for message: NCChatMessage) {
|
|
self.addOrRemoveReaction(reaction: reaction, in: message)
|
|
}
|
|
|
|
// MARK: - MessageSeparatorTableViewCellDelegate
|
|
|
|
override func generateSummaryButtonPressed() {
|
|
guard self.indexPathForUnreadMessageSeparator() != nil, let generateSummaryFromMessageId else { return }
|
|
|
|
self.generateSummary(fromMessageId: generateSummaryFromMessageId)
|
|
self.showGeneratingSummaryNotification()
|
|
}
|
|
|
|
func showGeneratingSummaryNotification() {
|
|
NotificationPresenter.shared().present(title: NSLocalizedString("Generating summary of unread messages", comment: ""), subtitle: NSLocalizedString("This might take a moment", comment: ""), includedStyle: .dark)
|
|
NotificationPresenter.shared().displayActivityIndicator(true)
|
|
}
|
|
|
|
func generateSummary(fromMessageId messageId: Int) {
|
|
NCAPIController.sharedInstance().summarizeChat(forAccountId: self.room.accountId, inRoom: self.room.token, fromMessageId: messageId) { status, taskId, nextOffset in
|
|
if status == .noAiProvider {
|
|
NotificationPresenter.shared().present(text: NSLocalizedString("No AI provider available or summarizing failed", comment: ""), dismissAfterDelay: 7.0, includedStyle: .error)
|
|
return
|
|
}
|
|
|
|
guard let taskId, status != .failed else {
|
|
NotificationPresenter.shared().present(text: NSLocalizedString("Generating summary of unread messages failed", comment: ""), dismissAfterDelay: 7.0, includedStyle: .error)
|
|
return
|
|
}
|
|
|
|
let hasRunningAiSummaryTasks = !AiSummaryController.shared.getSummaryTaskIds(forRoomInternalId: self.room.internalId).isEmpty
|
|
|
|
// No messages to summarize found, no previous tasks running and no more messages -> Nothing we can do, stop here
|
|
if status == .noMessagesFound, !hasRunningAiSummaryTasks, nextOffset == nil {
|
|
NotificationPresenter.shared().present(text: NSLocalizedString("No messages found to summarize", comment: ""), dismissAfterDelay: 7.0, includedStyle: .error)
|
|
return
|
|
}
|
|
|
|
// We might end up here with a status of "noMessagesFound". That can happen if we have previous running tasks, or got a nextOffset.
|
|
// Therefore we explictly check for "success" to only track tasks that were successfully submitted with messages
|
|
if status == .success {
|
|
AiSummaryController.shared.addSummaryTaskId(forRoomInternalId: self.room.internalId, withTaskId: taskId)
|
|
print("Scheduled summary task with taskId \(taskId) and nextOffset \(String(describing: nextOffset))")
|
|
}
|
|
|
|
// Add a safe-guard to make sure there's really a nextOffset. Otherwise we might end up requesting the same task over and over again
|
|
if let nextOffset, nextOffset > messageId {
|
|
// We were not able to get a summary of all messages at once, so we need to create another summary task
|
|
self.generateSummary(fromMessageId: nextOffset)
|
|
} else {
|
|
// There's no offset anymore (or there never was one) so we start checking the task states
|
|
self.scheduleSummaryTaskCheck()
|
|
}
|
|
}
|
|
}
|
|
|
|
func scheduleSummaryTaskCheck() {
|
|
self.generateSummaryTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false, block: { [weak self] _ in
|
|
guard
|
|
let self,
|
|
let firstTaskId = AiSummaryController.shared.getSummaryTaskIds(forRoomInternalId: self.room.internalId).first
|
|
else { return }
|
|
|
|
NCAPIController.sharedInstance().getAiTaskById(for: self.room.accountId, withTaskId: firstTaskId) { [weak self] status, output in
|
|
guard let self else { return }
|
|
|
|
if status == .successful {
|
|
let resultOutput = output ?? NSLocalizedString("Empty summary response", comment: "")
|
|
AiSummaryController.shared.markSummaryTaskAsDone(forRoomInternalId: self.room.internalId, withTaskId: firstTaskId, withOutput: resultOutput)
|
|
|
|
if AiSummaryController.shared.getSummaryTaskIds(forRoomInternalId: self.room.internalId).isEmpty {
|
|
// No more taskIds to check -> show the summary
|
|
NotificationPresenter.shared().dismiss()
|
|
|
|
let outputs = AiSummaryController.shared.finalizeSummaryTask(forRoomInternalId: self.room.internalId)
|
|
let summaryVC = AiSummaryViewController(summaryText: outputs.joined(separator: "\n\n---\n\n"))
|
|
let navController = UINavigationController(rootViewController: summaryVC)
|
|
self.present(navController, animated: true)
|
|
|
|
return
|
|
}
|
|
|
|
} else if status == .failed {
|
|
AiSummaryController.shared.finalizeSummaryTask(forRoomInternalId: self.room.internalId)
|
|
NotificationPresenter.shared().dismiss()
|
|
NotificationPresenter.shared().present(text: NSLocalizedString("Generating summary of unread messages failed", comment: ""), dismissAfterDelay: 7.0, includedStyle: .error)
|
|
|
|
return
|
|
} else if status == .cancelled {
|
|
AiSummaryController.shared.finalizeSummaryTask(forRoomInternalId: self.room.internalId)
|
|
NotificationPresenter.shared().dismiss()
|
|
return
|
|
}
|
|
|
|
self.scheduleSummaryTaskCheck()
|
|
}
|
|
})
|
|
}
|
|
|
|
// MARK: - ContextMenu (Long press on message)
|
|
|
|
func isMessageReplyable(message: NCChatMessage) -> Bool {
|
|
return message.isReplyable && !message.isDeleting
|
|
}
|
|
|
|
func isMessageReactable(message: NCChatMessage) -> Bool {
|
|
var isReactable = NCDatabaseManager.sharedInstance().roomHasTalkCapability(kCapabilityReactions, for: room)
|
|
isReactable = isReactable && !self.offlineMode
|
|
isReactable = isReactable && self.room.readOnlyState != .readOnly
|
|
isReactable = isReactable && !message.isDeletedMessage && !message.isCommandMessage && !message.sendingFailed && !message.isTemporary
|
|
|
|
return isReactable
|
|
}
|
|
|
|
func getSetReminderOptions(for message: NCChatMessage) -> [UIMenuElement] {
|
|
var reminderOptions: [UIMenuElement] = []
|
|
let now = Date()
|
|
|
|
let sunday = 1
|
|
let monday = 2
|
|
let friday = 6
|
|
let saturday = 7
|
|
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "EEE"
|
|
|
|
let setReminderCompletion: SetReminderForMessage = { (error: Error?) in
|
|
if error != nil {
|
|
NotificationPresenter.shared().present(text: NSLocalizedString("Error occurred when creating a reminder", comment: ""), dismissAfterDelay: 5.0, includedStyle: .error)
|
|
} else {
|
|
NotificationPresenter.shared().present(text: NSLocalizedString("Reminder was successfully set", comment: ""), dismissAfterDelay: 5.0, includedStyle: .success)
|
|
}
|
|
}
|
|
|
|
// Today
|
|
let laterTodayTime = NCUtils.today(withHour: 18, withMinute: 0, withSecond: 0)!
|
|
let laterToday = UIAction(title: NSLocalizedString("Later today", comment: "Remind me later today about that message"), subtitle: NCUtils.getTime(fromDate: laterTodayTime)) { _ in
|
|
let timestamp = String(Int(laterTodayTime.timeIntervalSince1970))
|
|
NCAPIController.sharedInstance().setReminderFor(message, withTimestamp: timestamp, withCompletionBlock: setReminderCompletion)
|
|
}
|
|
|
|
// Tomorrow
|
|
var tomorrowTime = NCUtils.today(withHour: 8, withMinute: 0, withSecond: 0)!
|
|
tomorrowTime = Calendar.current.date(byAdding: .day, value: 1, to: tomorrowTime)!
|
|
let tomorrow = UIAction(title: NSLocalizedString("Tomorrow", comment: "Remind me tomorrow about that message")) { _ in
|
|
let timestamp = String(Int(tomorrowTime.timeIntervalSince1970))
|
|
NCAPIController.sharedInstance().setReminderFor(message, withTimestamp: timestamp, withCompletionBlock: setReminderCompletion)
|
|
}
|
|
tomorrow.subtitle = "\(formatter.string(from: tomorrowTime)), \(NCUtils.getTime(fromDate: tomorrowTime))"
|
|
|
|
// This weekend
|
|
var weekendTime = NCUtils.today(withHour: 8, withMinute: 0, withSecond: 0)!
|
|
weekendTime = NCUtils.setWeekday(saturday, withDate: weekendTime)
|
|
let thisWeekend = UIAction(title: NSLocalizedString("This weekend", comment: "Remind me this weekend about that message")) { _ in
|
|
let timestamp = String(Int(weekendTime.timeIntervalSince1970))
|
|
NCAPIController.sharedInstance().setReminderFor(message, withTimestamp: timestamp, withCompletionBlock: setReminderCompletion)
|
|
}
|
|
thisWeekend.subtitle = "\(formatter.string(from: weekendTime)), \(NCUtils.getTime(fromDate: weekendTime))"
|
|
|
|
// Next week
|
|
var nextWeekTime = NCUtils.today(withHour: 8, withMinute: 0, withSecond: 0)!
|
|
nextWeekTime = Calendar.current.date(byAdding: .weekOfYear, value: 1, to: nextWeekTime)!
|
|
nextWeekTime = NCUtils.setWeekday(monday, withDate: nextWeekTime)
|
|
let nextWeek = UIAction(title: NSLocalizedString("Next week", comment: "Remind me next week about that message")) { _ in
|
|
let timestamp = String(Int(nextWeekTime.timeIntervalSince1970))
|
|
NCAPIController.sharedInstance().setReminderFor(message, withTimestamp: timestamp, withCompletionBlock: setReminderCompletion)
|
|
}
|
|
nextWeek.subtitle = "\(formatter.string(from: nextWeekTime)), \(NCUtils.getTime(fromDate: nextWeekTime))"
|
|
|
|
// Custom reminder
|
|
let customReminderAction = UIAction(title: NSLocalizedString("Pick date & time", comment: ""), image: .init(systemName: "calendar.badge.clock")) { [weak self] _ in
|
|
DispatchQueue.main.async {
|
|
guard let self else { return }
|
|
self.interactingMessage = message
|
|
self.lastMessageBeforeInteraction = self.tableView?.indexPathsForVisibleRows?.last
|
|
|
|
let startingDate = Calendar.current.date(byAdding: .hour, value: 1, to: now)
|
|
let minimumDate = Calendar.current.date(byAdding: .minute, value: 15, to: now)
|
|
|
|
self.datePickerTextField.getDate(startingDate: startingDate, minimumDate: minimumDate) { selectedDate in
|
|
let timestamp = String(Int(selectedDate.timeIntervalSince1970))
|
|
NCAPIController.sharedInstance().setReminderFor(message, withTimestamp: timestamp, withCompletionBlock: setReminderCompletion)
|
|
}
|
|
}
|
|
}
|
|
|
|
let customReminder = UIMenu(options: .displayInline, children: [customReminderAction])
|
|
|
|
// Hide "Later today" when it's past 5pm
|
|
if Calendar.current.component(.hour, from: now) < 17 {
|
|
reminderOptions.append(laterToday)
|
|
}
|
|
|
|
reminderOptions.append(tomorrow)
|
|
|
|
// Only show "This weekend" for Mon-Tue
|
|
let nowWeekday = Calendar.current.component(.weekday, from: now)
|
|
if nowWeekday != friday && nowWeekday != saturday && nowWeekday != sunday {
|
|
reminderOptions.append(thisWeekend)
|
|
}
|
|
|
|
// "Next week" should be hidden on sunday
|
|
if nowWeekday != sunday {
|
|
reminderOptions.append(nextWeek)
|
|
}
|
|
|
|
reminderOptions.append(customReminder)
|
|
|
|
return reminderOptions
|
|
}
|
|
|
|
override func getContextMenuAccessoryView(forMessage message: NCChatMessage, forIndexPath indexPath: IndexPath, withCellHeight cellHeight: CGFloat) -> UIView? {
|
|
let hasChatPermissions = !NCDatabaseManager.sharedInstance().roomHasTalkCapability(kCapabilityChatPermission, for: room) || self.room.permissions.contains(.chat)
|
|
|
|
guard hasChatPermissions && self.isMessageReactable(message: message) else { return nil }
|
|
|
|
let reactionViewPadding = 10
|
|
let emojiButtonPadding = 10
|
|
let emojiButtonSize = 48
|
|
let frequentlyUsedEmojis = NCDatabaseManager.sharedInstance().activeAccount().frequentlyUsedEmojis
|
|
|
|
let totalEmojiButtonWidth = frequentlyUsedEmojis.count * emojiButtonSize
|
|
let totalEmojiButtonPadding = frequentlyUsedEmojis.count * emojiButtonPadding
|
|
let addButtonWidth = emojiButtonSize + emojiButtonPadding
|
|
|
|
// We need to add an extra padding to the right so the buttons are correctly padded
|
|
let reactionViewWidth = totalEmojiButtonWidth + totalEmojiButtonPadding + addButtonWidth + emojiButtonPadding
|
|
let reactionView = UIView(frame: .init(x: 0, y: Int(cellHeight) + reactionViewPadding, width: reactionViewWidth, height: emojiButtonSize))
|
|
|
|
var positionX = emojiButtonPadding
|
|
|
|
for emoji in frequentlyUsedEmojis {
|
|
let emojiShortcutButton = UIButton(type: .system)
|
|
emojiShortcutButton.frame = CGRect(x: positionX, y: 0, width: emojiButtonSize, height: emojiButtonSize)
|
|
emojiShortcutButton.layer.cornerRadius = CGFloat(emojiButtonSize) / 2
|
|
|
|
emojiShortcutButton.titleLabel?.font = .systemFont(ofSize: 20)
|
|
emojiShortcutButton.setTitle(emoji, for: .normal)
|
|
emojiShortcutButton.backgroundColor = .systemBackground
|
|
|
|
emojiShortcutButton.addAction { [weak self] in
|
|
guard let self else { return }
|
|
self.tableView?.contextMenuInteraction?.dismissMenu()
|
|
|
|
self.contextMenuActionBlock = {
|
|
self.addReaction(reaction: emoji, to: message)
|
|
}
|
|
}
|
|
|
|
// Disable shortcuts, if we already reacted with that emoji
|
|
for reaction in message.reactionsArray() {
|
|
if reaction.reaction == emoji && reaction.userReacted {
|
|
emojiShortcutButton.isEnabled = false
|
|
emojiShortcutButton.alpha = 0.4
|
|
break
|
|
}
|
|
}
|
|
|
|
reactionView.addSubview(emojiShortcutButton)
|
|
positionX += emojiButtonSize + emojiButtonPadding
|
|
}
|
|
|
|
let addReactionButton = UIButton(type: .system)
|
|
addReactionButton.frame = CGRect(x: positionX, y: 0, width: emojiButtonSize, height: emojiButtonSize)
|
|
addReactionButton.layer.cornerRadius = CGFloat(emojiButtonSize) / 2
|
|
|
|
addReactionButton.titleLabel?.font = .systemFont(ofSize: 22)
|
|
addReactionButton.setImage(.init(systemName: "plus"), for: .normal)
|
|
addReactionButton.tintColor = .label
|
|
addReactionButton.backgroundColor = .systemBackground
|
|
addReactionButton.addAction { [weak self] in
|
|
guard let self else { return }
|
|
self.tableView?.contextMenuInteraction?.dismissMenu()
|
|
|
|
self.contextMenuActionBlock = {
|
|
self.didPressAddReaction(for: message, at: indexPath)
|
|
}
|
|
}
|
|
|
|
reactionView.addSubview(addReactionButton)
|
|
|
|
// The reactionView will be shown after the animation finishes, otherwise we see the view already when animating and this looks odd
|
|
reactionView.alpha = 0
|
|
reactionView.layer.cornerRadius = CGFloat(emojiButtonSize) / 2
|
|
reactionView.backgroundColor = .systemBackground
|
|
|
|
return reactionView
|
|
}
|
|
|
|
// swiftlint:disable:next cyclomatic_complexity
|
|
public override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
|
if tableView == self.autoCompletionView {
|
|
return nil
|
|
}
|
|
|
|
let cell = tableView.cellForRow(at: indexPath)
|
|
|
|
// Show reactionSummary for legacy cells
|
|
if let cell = cell as? ChatTableViewCell {
|
|
let pointInCell = tableView.convert(point, to: cell)
|
|
let reactionView = cell.contentView.subviews.first(where: { $0 is ReactionsView && $0.frame.contains(pointInCell) })
|
|
|
|
if reactionView != nil {
|
|
self.showReactionsSummary(of: cell.message)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
if let cell = cell as? BaseChatTableViewCell {
|
|
let pointInCell = tableView.convert(point, to: cell)
|
|
let pointInReactionPart = cell.convert(pointInCell, to: cell.reactionPart)
|
|
let reactionView = cell.reactionPart.subviews.first(where: { $0 is ReactionsView && $0.frame.contains(pointInReactionPart) })
|
|
|
|
if reactionView != nil, let message = cell.message {
|
|
self.showReactionsSummary(of: message)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
guard let message = self.message(for: indexPath)
|
|
else { return nil }
|
|
|
|
if message.isSystemMessage || message.isDeletedMessage ||
|
|
message.messageId == MessageSeparatorTableViewCell.unreadMessagesSeparatorId ||
|
|
message.messageId == MessageSeparatorTableViewCell.unreadMessagesWithSummarySeparatorId ||
|
|
message.messageId == MessageSeparatorTableViewCell.chatBlockSeparatorId {
|
|
|
|
return nil
|
|
}
|
|
|
|
var actions: [UIMenuElement] = []
|
|
var informationalActions: [UIMenuElement] = []
|
|
let hasChatPermissions = !NCDatabaseManager.sharedInstance().roomHasTalkCapability(kCapabilityChatPermission, for: room) || self.room.permissions.contains(.chat)
|
|
|
|
// Show edit information
|
|
if let lastEditActorDisplayName = message.lastEditActorDisplayName, message.lastEditTimestamp > 0 {
|
|
let timestampDate = Date(timeIntervalSince1970: TimeInterval(message.lastEditTimestamp))
|
|
|
|
let editInfo = UIAction(title: NSLocalizedString("Edited by", comment: "A message was edited by ...") + " " + lastEditActorDisplayName, attributes: [.disabled], handler: {_ in })
|
|
editInfo.subtitle = NCUtils.readableTimeAndDate(fromDate: timestampDate)
|
|
|
|
informationalActions.append(editInfo)
|
|
}
|
|
|
|
// Show silent send information
|
|
if message.isSilent {
|
|
let silentInfo = UIAction(title: NSLocalizedString("Sent without notification", comment: "A message has been sent without notifications"), attributes: [.disabled], handler: {_ in })
|
|
silentInfo.image = UIImage(systemName: "bell.slash")
|
|
|
|
informationalActions.append(silentInfo)
|
|
}
|
|
|
|
if !informationalActions.isEmpty {
|
|
actions.append(UIMenu(options: [.displayInline], children: informationalActions))
|
|
}
|
|
|
|
// Reply option
|
|
if self.isMessageReplyable(message: message), hasChatPermissions, !self.textInputbar.isEditing {
|
|
actions.append(UIAction(title: NSLocalizedString("Reply", comment: ""), image: .init(systemName: "arrowshape.turn.up.left")) { _ in
|
|
self.didPressReply(for: message)
|
|
})
|
|
}
|
|
|
|
// Show "Add reaction" when running on MacOS because we don't have an accessory view
|
|
if self.isMessageReactable(message: message), hasChatPermissions, NCUtils.isiOSAppOnMac() {
|
|
actions.append(UIAction(title: NSLocalizedString("Add reaction", comment: ""), image: .init(systemName: "face.smiling")) { _ in
|
|
self.didPressAddReaction(for: message, at: indexPath)
|
|
})
|
|
}
|
|
|
|
// Forward option (only normal messages for now)
|
|
if message.file() == nil, message.poll == nil, !message.isDeletedMessage {
|
|
actions.append(UIAction(title: NSLocalizedString("Forward", comment: ""), image: .init(systemName: "arrowshape.turn.up.right")) { _ in
|
|
self.didPressForward(for: message)
|
|
})
|
|
}
|
|
|
|
var copyMenuActions: [UIMenuElement] = []
|
|
|
|
// Copy option
|
|
copyMenuActions.append(UIAction(title: NSLocalizedString("Message", comment: "Copy 'message'"), image: .init(systemName: "doc.text")) { _ in
|
|
self.didPressCopy(for: message)
|
|
})
|
|
|
|
// Copy part option
|
|
copyMenuActions.append(UIAction(title: NSLocalizedString("Selection", comment: "Copy a 'selection' of a message"), image: .init(systemName: "text.viewfinder")) { _ in
|
|
self.didPressCopySelection(for: message)
|
|
})
|
|
|
|
// Copy link option
|
|
copyMenuActions.append(UIAction(title: NSLocalizedString("Message link", comment: "Copy 'link' to a message"), image: .init(systemName: "link")) { _ in
|
|
self.didPressCopyLink(for: message)
|
|
})
|
|
|
|
actions.append(UIMenu(title: NSLocalizedString("Copy", comment: ""), image: .init(systemName: "doc.on.doc"), children: copyMenuActions))
|
|
|
|
// Remind me later
|
|
if !message.sendingFailed, !message.isOfflineMessage, NCDatabaseManager.sharedInstance().roomHasTalkCapability(kCapabilityRemindMeLater, for: room) {
|
|
let deferredMenuElement = UIDeferredMenuElement.uncached { [weak self] completion in
|
|
NCAPIController.sharedInstance().getReminderFor(message) { [weak self] response, error in
|
|
guard let self else { return }
|
|
|
|
var menuOptions: [UIMenuElement] = []
|
|
menuOptions.append(contentsOf: self.getSetReminderOptions(for: message))
|
|
|
|
if error == nil,
|
|
let responseDict = response as? [String: Any],
|
|
let timestamp = responseDict["timestamp"] as? Int {
|
|
|
|
// There's already an existing reminder set for this message
|
|
// -> offer a delete option
|
|
let timestampDate = Date(timeIntervalSince1970: TimeInterval(timestamp))
|
|
|
|
let clearAction = UIAction(title: NSLocalizedString("Clear reminder", comment: ""), image: .init(systemName: "trash")) { _ in
|
|
NCAPIController.sharedInstance().deleteReminder(for: message) { error in
|
|
if error == nil {
|
|
NotificationPresenter.shared().present(text: NSLocalizedString("Reminder was successfully cleared", comment: ""), dismissAfterDelay: 5.0, includedStyle: .success)
|
|
} else {
|
|
NotificationPresenter.shared().present(text: NSLocalizedString("Failed to clear reminder", comment: ""), dismissAfterDelay: 5.0, includedStyle: .error)
|
|
}
|
|
}
|
|
}
|
|
clearAction.subtitle = NCUtils.readableDateTime(fromDate: timestampDate)
|
|
clearAction.attributes = .destructive
|
|
|
|
menuOptions.append(UIMenu(options: .displayInline, children: [clearAction]))
|
|
}
|
|
|
|
completion(menuOptions)
|
|
}
|
|
}
|
|
|
|
actions.append(UIMenu(title: NSLocalizedString("Set reminder", comment: "Remind me later about that message"),
|
|
image: .init(systemName: "alarm"),
|
|
children: [deferredMenuElement]))
|
|
}
|
|
|
|
// Re-send option
|
|
if (message.sendingFailed || message.isOfflineMessage) && hasChatPermissions {
|
|
actions.append(UIAction(title: NSLocalizedString("Resend", comment: ""), image: .init(systemName: "arrow.clockwise")) { _ in
|
|
self.didPressResend(for: message)
|
|
})
|
|
}
|
|
|
|
// Open in nextcloud option
|
|
if !self.offlineMode, message.file() != nil {
|
|
let openInNextcloudTitle = String(format: NSLocalizedString("Open in %@", comment: ""), filesAppName)
|
|
actions.append(UIAction(title: openInNextcloudTitle, image: .init(named: "logo-action")?.withRenderingMode(.alwaysTemplate)) { _ in
|
|
self.didPressOpenInNextcloud(for: message)
|
|
})
|
|
}
|
|
|
|
// Transcribe voice-message
|
|
if message.messageType == kMessageTypeVoiceMessage {
|
|
let transcribeTitle = NSLocalizedString("Transcribe", comment: "TRANSLATORS this is for transcribing a voice message to text")
|
|
actions.append(UIAction(title: transcribeTitle, image: .init(systemName: "text.bubble")) { _ in
|
|
self.didPressTranscribeVoiceMessage(for: message)
|
|
})
|
|
}
|
|
|
|
var moreMenuActions: [UIMenuElement] = []
|
|
|
|
// Reply-privately option (only to other users and not in one-to-one)
|
|
if self.isMessageReplyable(message: message), self.room.type != .oneToOne, message.actorType == "users", message.actorId != self.account.userId {
|
|
moreMenuActions.append(UIAction(title: NSLocalizedString("Reply privately", comment: ""), image: .init(systemName: "person")) { _ in
|
|
self.didPressReplyPrivately(for: message)
|
|
})
|
|
}
|
|
|
|
// Translate
|
|
if !self.offlineMode, NCDatabaseManager.sharedInstance().hasAvailableTranslations(forAccountId: self.account.accountId) {
|
|
moreMenuActions.append(UIAction(title: NSLocalizedString("Translate", comment: ""), image: .init(systemName: "character.book.closed")) { _ in
|
|
self.didPressTranslate(for: message)
|
|
})
|
|
}
|
|
|
|
// Note to self
|
|
if message.file() == nil, message.poll == nil, !message.isDeletedMessage, room.type != .noteToSelf,
|
|
NCDatabaseManager.sharedInstance().roomHasTalkCapability(kCapabilityNoteToSelf, for: room) {
|
|
moreMenuActions.append(UIAction(title: NSLocalizedString("Note to self", comment: ""), image: .init(systemName: "square.and.pencil")) { _ in
|
|
self.didPressNoteToSelf(for: message)
|
|
})
|
|
}
|
|
|
|
if moreMenuActions.count == 1, let firstElement = moreMenuActions.first {
|
|
// When there's only one element, no need to create a "More" menu
|
|
actions.append(firstElement)
|
|
} else if !moreMenuActions.isEmpty {
|
|
actions.append(UIMenu(title: NSLocalizedString("More", comment: "More menu elements"), children: moreMenuActions))
|
|
}
|
|
|
|
var destructiveMenuActions: [UIMenuElement] = []
|
|
|
|
// Edit option
|
|
if message.isEditable(for: self.account, in: self.room) && hasChatPermissions {
|
|
destructiveMenuActions.append(UIAction(title: NSLocalizedString("Edit", comment: "Edit a message or room participants"), image: .init(systemName: "pencil")) { _ in
|
|
self.didPressEdit(for: message)
|
|
})
|
|
}
|
|
|
|
// Delete option
|
|
if message.sendingFailed || message.isOfflineMessage || (message.isDeletable(for: self.account, in: self.room) && hasChatPermissions) {
|
|
destructiveMenuActions.append(UIAction(title: NSLocalizedString("Delete", comment: ""), image: .init(systemName: "trash"), attributes: .destructive) { _ in
|
|
self.didPressDelete(for: message)
|
|
})
|
|
}
|
|
|
|
if !destructiveMenuActions.isEmpty {
|
|
actions.append(UIMenu(options: [.displayInline], children: destructiveMenuActions))
|
|
}
|
|
|
|
let menu = UIMenu(children: actions)
|
|
|
|
let configuration = UIContextMenuConfiguration(identifier: indexPath as NSIndexPath) {
|
|
return nil
|
|
} actionProvider: { _ in
|
|
return menu
|
|
}
|
|
|
|
return configuration
|
|
}
|
|
|
|
// MARK: - NCChatTitleViewDelegate
|
|
|
|
public override func chatTitleViewTapped(_ titleView: NCChatTitleView!) {
|
|
guard let roomInfoVC = RoomInfoTableViewController(for: self.room, from: self) else { return }
|
|
roomInfoVC.hideDestructiveActions = self.presentedInCall
|
|
|
|
if let splitViewController = NCUserInterfaceController.sharedInstance().mainViewController {
|
|
if !splitViewController.isCollapsed {
|
|
roomInfoVC.modalPresentationStyle = .pageSheet
|
|
let navController = UINavigationController(rootViewController: roomInfoVC)
|
|
self.present(navController, animated: true)
|
|
} else {
|
|
self.navigationController?.pushViewController(roomInfoVC, animated: true)
|
|
}
|
|
} else {
|
|
self.navigationController?.pushViewController(roomInfoVC, animated: true)
|
|
}
|
|
|
|
// When returning from RoomInfoTableViewController the default keyboard will be shown, so the height might be wrong -> make sure the keyboard is hidden
|
|
self.dismissKeyboard(true)
|
|
}
|
|
}
|