diff --git a/NextcloudTalk.xcodeproj/project.pbxproj b/NextcloudTalk.xcodeproj/project.pbxproj index b23f375a1..08d355d7d 100644 --- a/NextcloudTalk.xcodeproj/project.pbxproj +++ b/NextcloudTalk.xcodeproj/project.pbxproj @@ -538,6 +538,7 @@ NCMessageLocationParameter.m, NCMessageParameter.m, NCPoll.m, + ScheduledMessage.swift, ); target = 2CC0014E24A1F0E900A20167 /* NotificationServiceExtension */; }; @@ -569,6 +570,7 @@ NCMessageLocationParameter.m, NCMessageParameter.m, NCPoll.m, + ScheduledMessage.swift, ); target = 2C62AFA224C08845007E460A /* ShareExtension */; }; @@ -589,6 +591,7 @@ NCMessageLocationParameter.m, NCMessageParameter.m, NCPoll.m, + ScheduledMessage.swift, ); target = 1FF2FD5A2AB99CCB000C9905 /* BroadcastUploadExtension */; }; @@ -609,6 +612,7 @@ NCMessageLocationParameter.m, NCMessageParameter.m, NCPoll.m, + ScheduledMessage.swift, ); target = 1FA93D9B2D70FCC200DF6CDF /* TalkIntents */; }; diff --git a/NextcloudTalk/Chat/BaseChatViewController.swift b/NextcloudTalk/Chat/BaseChatViewController.swift index c6fd0f993..4a3c7f404 100644 --- a/NextcloudTalk/Chat/BaseChatViewController.swift +++ b/NextcloudTalk/Chat/BaseChatViewController.swift @@ -704,7 +704,13 @@ import SwiftUI func showVoiceMessageRecordButton() { self.rightButton.setTitle("", for: .normal) - self.rightButton.setImage(UIImage(systemName: "mic"), for: .normal) + + if self.room.hasScheduledMessages { + self.rightButton.setImage(UIImage(systemName: "clock"), for: .normal) + } else { + self.rightButton.setImage(UIImage(systemName: "mic"), for: .normal) + } + self.rightButton.tag = sendButtonTagVoice self.rightButton.accessibilityLabel = NSLocalizedString("Record voice message", comment: "") self.rightButton.accessibilityHint = NSLocalizedString("Tap and hold to record a voice message", comment: "") @@ -738,6 +744,40 @@ import SwiftUI let messageParameters = NCMessageParameter.messageParametersJSONString(from: self.mentionsDict) ?? "" self.sendChatMessage(message: self.textView.text, withParentMessage: replyToMessage, messageParameters: messageParameters, silently: silently) + self.clearInputAfterSend() + } + + func sendCurrentMessageLater(silently: Bool) { + Task { @MainActor in + let startingDate = Calendar.current.date(byAdding: .hour, value: 1, to: Date()) + let minimumDate = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) + + self.datePickerTextField.setupDatePicker(startingDate: startingDate, minimumDate: minimumDate) + + let (buttonTapped, selectedDate) = await self.datePickerTextField.getDate() + guard buttonTapped == .done, let selectedDate else { return } + + do { + let timestamp = Int(selectedDate.timeIntervalSince1970) + var replyToMessage: NCChatMessage? + + if let replyMessageView, replyMessageView.isVisible { + replyToMessage = replyMessageView.message + } + + try await NCAPIController.sharedInstance().scheduleMessage(self.textView.text, inRoom: self.room.token, sendAt: timestamp, replyTo: replyToMessage?.messageId, silent: silently, threadId: self.thread?.threadId, forAccount: self.account) + NotificationPresenter.shared().present(text: NSLocalizedString("Message successfully scheduled", comment: ""), dismissAfterDelay: 5.0, includedStyle: .success) + + self.clearInputAfterSend() + NCRoomsManager.sharedInstance().updateRoom(self.room.token, withCompletionBlock: nil) + } catch { + print(error) + NotificationPresenter.shared().present(text: NSLocalizedString("Message scheduling failed", comment: ""), dismissAfterDelay: 5.0, includedStyle: .error) + } + } + } + + private func clearInputAfterSend() { self.mentionsDict.removeAll() self.replyMessageView?.dismiss() super.didPressRightButton(self) @@ -753,7 +793,12 @@ import SwiftUI self.sendCurrentMessage(silently: false) super.didPressRightButton(sender) case sendButtonTagVoice: - self.showVoiceMessageRecordHint() + if self.room.hasScheduledMessages { + let scheduledViewController = ScheduledMessagesChatViewController(forRoom: self.room, withAccount: self.account)! + self.presentWithNavigation(scheduledViewController, animated: true) + } else { + self.showVoiceMessageRecordHint() + } default: break } @@ -779,11 +824,23 @@ import SwiftUI self.voiceMessageLongPressGesture = nil } - let silentSendAction = UIAction(title: NSLocalizedString("Send without notification", comment: ""), image: UIImage(systemName: "bell.slash")) { [unowned self] _ in - self.sendCurrentMessage(silently: true) + var actions: [UIMenuElement] = [] + + if NCDatabaseManager.sharedInstance().serverHasTalkCapability(kCapabilityScheduleMessages, forAccountId: self.account.accountId) { + actions.append(UIAction(title: NSLocalizedString("Send later without notification", comment: ""), image: UIImage(named: "custom.paperplane.badge.clock")) { [unowned self] _ in + self.sendCurrentMessageLater(silently: true) + }) + + actions.append(UIAction(title: NSLocalizedString("Send later", comment: ""), image: UIImage(named: "custom.paperplane.badge.clock")) { [unowned self] _ in + self.sendCurrentMessageLater(silently: false) + }) } - self.rightButton.menu = UIMenu(children: [silentSendAction]) + actions.append(UIAction(title: NSLocalizedString("Send without notification", comment: ""), image: UIImage(systemName: "bell.slash")) { [unowned self] _ in + self.sendCurrentMessage(silently: true) + }) + + self.rightButton.menu = UIMenu(children: actions) } func addMenuToLeftButton() { @@ -1738,6 +1795,7 @@ import SwiftUI self.recordCancelled = true self.stopRecordingVoiceMessage() handleCollapseVoiceRecording() + self.showVoiceMessageRecordButton() } func handleSend() { @@ -1842,7 +1900,9 @@ import SwiftUI } func shareVoiceMessage() { + self.showVoiceMessageRecordButton() guard let recorder = self.recorder else { return } + let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd HH-mm-ss" let dateString = dateFormatter.string(from: Date()) @@ -1960,6 +2020,8 @@ import SwiftUI if flag, recorder == self.recorder, !self.recordCancelled { self.shareVoiceMessage() } + + self.showVoiceMessageRecordButton() } // MARK: - Voice Messages Transcribe @@ -2129,6 +2191,7 @@ import SwiftUI self.longPressStartingPoint = point self.cancelHintLabelInitialPositionX = voiceMessageRecordingView?.slideToCancelHintLabel?.frame.origin.x self.voiceRecordingLockButton.alpha = 1 + self.rightButton.setImage(UIImage(systemName: "mic"), for: .normal) } else if gestureRecognizer.state == .ended { self.shouldLockInterfaceOrientation(lock: false) self.resetVoiceRecordingLockButton() diff --git a/NextcloudTalk/Chat/Chat views/NCChatTitleView.h b/NextcloudTalk/Chat/Chat views/NCChatTitleView.h index 8a71770df..898c74e2b 100644 --- a/NextcloudTalk/Chat/Chat views/NCChatTitleView.h +++ b/NextcloudTalk/Chat/Chat views/NCChatTitleView.h @@ -27,6 +27,7 @@ @property (strong, nonatomic) UILongPressGestureRecognizer *longPressGestureRecognizer; - (void)updateForRoom:(NCRoom *)room; +- (void)updateForScheduledMessagesIn:(NCRoom *)room; - (void)updateForThread:(NCThread *)thread; @end diff --git a/NextcloudTalk/Chat/Chat views/NCChatTitleView.m b/NextcloudTalk/Chat/Chat views/NCChatTitleView.m index bac2e75a1..b88b6672e 100644 --- a/NextcloudTalk/Chat/Chat views/NCChatTitleView.m +++ b/NextcloudTalk/Chat/Chat views/NCChatTitleView.m @@ -111,6 +111,11 @@ - (void)updateForRoom:(NCRoom *)room [self setTitle:room.displayName withSubtitle:subtitle]; } +- (void)updateForScheduledMessagesIn:(NCRoom *)room +{ + [self setTitle:NSLocalizedString(@"Scheduled messages", nil) withSubtitle:room.displayName]; +} + - (void)updateForThread:(NCThread *)thread { // Set thread image diff --git a/NextcloudTalk/Chat/ChatViewController.swift b/NextcloudTalk/Chat/ChatViewController.swift index 5497be159..dffe39ee7 100644 --- a/NextcloudTalk/Chat/ChatViewController.swift +++ b/NextcloudTalk/Chat/ChatViewController.swift @@ -60,10 +60,6 @@ import SwiftUI private var lobbyCheckTimer: Timer? - public var isThreadViewController: Bool { - return thread != nil - } - // MARK: - Thread notification levels enum NotificationLevelOption: Int, CaseIterable { @@ -511,15 +507,6 @@ import SwiftUI private var messageExpirationTimer: Timer? - override func setTitleView() { - super.setTitleView() - - if isThreadViewController { - self.titleView?.update(for: thread) - self.titleView?.longPressGestureRecognizer.isEnabled = false - } - } - public override init?(forRoom room: NCRoom, withAccount account: TalkAccount) { self.chatController = NCChatController(for: room) @@ -1322,6 +1309,10 @@ import SwiftUI } self.checkRetention() + + // Update right button after receiving a room update (for scheduled messages) + // TODO: Button update should be a separate method + _ = self.canPressRightButton() } func didJoinRoom(notification: Notification) { diff --git a/NextcloudTalk/Chat/ContextChatViewController.swift b/NextcloudTalk/Chat/ContextChatViewController.swift index beeeb726c..aa4e94aaf 100644 --- a/NextcloudTalk/Chat/ContextChatViewController.swift +++ b/NextcloudTalk/Chat/ContextChatViewController.swift @@ -10,10 +10,6 @@ import Foundation override func setTitleView() { super.setTitleView() - if thread != nil { - self.titleView?.update(for: thread) - } - self.titleView?.longPressGestureRecognizer.isEnabled = false } diff --git a/NextcloudTalk/Chat/InputbarViewController.swift b/NextcloudTalk/Chat/InputbarViewController.swift index 06bec3697..c556b645b 100644 --- a/NextcloudTalk/Chat/InputbarViewController.swift +++ b/NextcloudTalk/Chat/InputbarViewController.swift @@ -22,6 +22,10 @@ import UIKit internal var contentView: UIView? internal var selectedAutocompletionRow: IndexPath? + public var isThreadViewController: Bool { + return thread != nil + } + public init?(forRoom room: NCRoom, withAccount account: TalkAccount, tableViewStyle style: UITableView.Style) { self.room = room self.account = account @@ -233,7 +237,13 @@ import UIKit titleView.showSubtitle = false } - titleView.update(for: self.room) + if isThreadViewController { + titleView.update(for: thread) + titleView.longPressGestureRecognizer.isEnabled = false + } else { + titleView.update(for: self.room) + } + self.titleView = titleView self.navigationItem.titleView = titleView } diff --git a/NextcloudTalk/Chat/ScheduledMessage.swift b/NextcloudTalk/Chat/ScheduledMessage.swift new file mode 100644 index 000000000..80c1a80ca --- /dev/null +++ b/NextcloudTalk/Chat/ScheduledMessage.swift @@ -0,0 +1,65 @@ +// +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation + +public class ScheduledMessage { + + public var id: String + public var actor: TalkActor? + public var threadId: Int = 0 + public var message: String + public var messageType: String + public var parentMessage: NCChatMessage? + public var silent: Bool + public var createdAtTimestamp: Int + public var sendAtTimestamp: Int + + private var account: TalkAccount + + init?(dictionary dict: [String: Any]?, withAccount account: TalkAccount) { + guard let dict else { return nil } + + self.account = account + + self.id = dict["id"] as? String ?? "" + self.threadId = dict["threadId"] as? Int ?? 0 + self.message = dict["message"] as? String ?? "" + self.messageType = dict["messageType"] as? String ?? "" + self.silent = dict["silent"] as? Bool ?? false + self.createdAtTimestamp = dict["createdAt"] as? Int ?? 0 + self.sendAtTimestamp = dict["sendAt"] as? Int ?? 0 + + if let actorId = dict["actorId"] as? String, let actorType = dict["actorType"] as? String { + self.actor = TalkActor(actorId: actorId, actorType: actorType) + } + + if let parentMessage = dict["parent"] as? [String: Any] { + self.parentMessage = NCChatMessage(dictionary: parentMessage, andAccountId: account.accountId) + } + } + + public func asChatMessage() -> NCChatMessage { + let message = NCChatMessage() + + message.messageId = Int(self.id) ?? 0 + message.actorId = self.account.userId + message.actorType = "users" + message.actorDisplayName = account.userDisplayName + message.accountId = self.account.accountId + + message.timestamp = self.sendAtTimestamp + message.message = self.message + message.isSilent = self.silent + + if let parentMessage { + message.parentId = parentMessage.internalId + } + + message.threadId = self.threadId + + return message + } +} diff --git a/NextcloudTalk/Chat/ScheduledMessagesChatViewController.swift b/NextcloudTalk/Chat/ScheduledMessagesChatViewController.swift new file mode 100644 index 000000000..65b89617f --- /dev/null +++ b/NextcloudTalk/Chat/ScheduledMessagesChatViewController.swift @@ -0,0 +1,212 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation + +@objcMembers public class ScheduledMessagesChatViewController: BaseChatViewController { + + public override init?(forRoom room: NCRoom, withAccount account: TalkAccount) { + super.init(forRoom: room, withAccount: account) + + // No need for an input bar when viewing scheduled messages + self.textInputbar.isHidden = true + + // Scroll to bottom manually after hiding the textInputbar, otherwise the + // scrollToBottom button might be briefly visible even if not needed + self.tableView?.slk_scrollToBottom(animated: false) + + let closeButton = UIBarButtonItem(title: nil, style: .plain, target: nil, action: nil) + closeButton.primaryAction = UIAction(title: NSLocalizedString("Close", comment: ""), handler: { [unowned self] _ in + self.dismiss(animated: true) + }) + self.navigationItem.rightBarButtonItems = [closeButton] + + Task { + await self.showScheduledMessages() + } + } + + @MainActor required init?(coder decoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setTitleView() { + super.setTitleView() + + self.titleView?.updateForScheduledMessages(in: self.room) + } + + public func showScheduledMessages() async { + do { + let scheduledMessages = try await NCAPIController.sharedInstance().getScheduledMessages(forRoom: self.room.token, forAccount: self.account) + self.appendMessages(messages: scheduledMessages.compactMap { $0.asChatMessage() }) + self.tableView?.reloadData() + self.tableView?.slk_scrollToBottom(animated: false) + } catch { + + } + + self.chatBackgroundView.loadingView.stopAnimating() + self.chatBackgroundView.loadingView.isHidden = true + } + + // MARK: - Editing support + + public override func didCommitTextEditing(_ sender: Any) { + guard let editingMessage else { return } + + let messageParametersJSONString = NCMessageParameter.messageParametersJSONString(from: self.mentionsDict) ?? "" + editingMessage.message = self.replaceMentionsDisplayNamesWithMentionsKeysInMessage(message: self.textView.text, parameters: messageParametersJSONString) + editingMessage.messageParametersJSONString = messageParametersJSONString + + Task { + do { + let updatedMessage = try await NCAPIController.sharedInstance().editScheduledMessage(String(editingMessage.messageId), withMessage: editingMessage.sendingMessage, inRoom: self.room.token, sendAt: editingMessage.timestamp, forAccount: self.account) + self.updateMessage(withMessageId: editingMessage.messageId, updatedMessage: updatedMessage.asChatMessage()) + super.didCommitTextEditing(sender) + self.setTextInputbarHidden(true, animated: true) + + NotificationPresenter.shared().present(text: NSLocalizedString("Message successfully edited", comment: ""), dismissAfterDelay: 5.0, includedStyle: .success) + } catch { + print(error) + NotificationPresenter.shared().present(text: NSLocalizedString("Message editing failed", comment: ""), dismissAfterDelay: 5.0, includedStyle: .error) + } + } + } + + public override func didCancelTextEditing(_ sender: Any) { + super.didCancelTextEditing(sender) + self.setTextInputbarHidden(true, animated: true) + } + + // MARK: - Action methods + + func didPressReschedule(for message: NCChatMessage, at indexPath: IndexPath) async { + let startingDate = Date(timeIntervalSince1970: TimeInterval(message.timestamp)) + let minimumDate = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) + + self.datePickerTextField.setupDatePicker(startingDate: startingDate, minimumDate: minimumDate) + + let (buttonTapped, selectedDate) = await self.datePickerTextField.getDate() + guard buttonTapped == .done, let selectedDate else { return } + + do { + let timestamp = Int(selectedDate.timeIntervalSince1970) + let updatedMessage = try await NCAPIController.sharedInstance().editScheduledMessage(String(message.messageId), withMessage: message.sendingMessage, inRoom: self.room.token, sendAt: timestamp, forAccount: self.account) + + // TODO: Update message does not support moving to a different section, therefore we remove and insert the updated message + self.removeMessage(at: indexPath) + self.insertMessages(messages: [updatedMessage.asChatMessage()]) + self.tableView?.reloadData() + + NotificationPresenter.shared().present(text: NSLocalizedString("Message successfully rescheduled", comment: ""), dismissAfterDelay: 5.0, includedStyle: .success) + } catch { + print(error) + NotificationPresenter.shared().present(text: NSLocalizedString("Message rescheduling failed", comment: ""), dismissAfterDelay: 5.0, includedStyle: .error) + } + } + + func didPressSendNow(for message: NCChatMessage, at indexPath: IndexPath) { + // "Duplicate" code, since sendChatMessage is not available in BaseChatViewController, + // but we can't just move the code to ChatViewController as we then need a second ChatController instance + var replyTo = message.parent + + // On thread view, include original thread message as parent message (if there is not parent) + if let thread = self.thread, replyTo == nil { + replyTo = thread.firstMessage() + } + + guard + let temporaryMessage = self.createTemporaryMessage(message: message.message, replyTo: replyTo, messageParameters: message.messageParametersJSONString ?? "", silently: message.isSilent, isVoiceMessage: false), + let chatController = NCChatController(for: self.room) + else { return } + + // Send message + chatController.send(temporaryMessage) + + Task { + await self.didPressDeleteScheduled(for: message, at: indexPath) + } + + self.dismiss(animated: true) + NCRoomsManager.sharedInstance().updateRoom(self.room.token, withCompletionBlock: nil) + } + + func didPressDeleteScheduled(for message: NCChatMessage, at indexPath: IndexPath) async { + do { + try await NCAPIController.sharedInstance().deleteScheduledMessage(String(message.messageId), inRoom: self.room.token, forAccount: self.account) + } catch { + NotificationPresenter.shared().present(text: NSLocalizedString("An error occurred while deleting the message", comment: ""), dismissAfterDelay: 5.0, includedStyle: .error) + return + } + + self.removeMessage(at: indexPath) + + if self.messages.values.isEmpty { + self.dismiss(animated: true) + NCRoomsManager.sharedInstance().updateRoom(self.room.token, withCompletionBlock: nil) + } + } + + // MARK: - TableView overrides + + public override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + guard let message = self.message(for: indexPath) else { return nil } + + var actions: [UIMenuElement] = [] + + // Copy option + actions.append(UIAction(title: NSLocalizedString("Copy", comment: ""), image: .init(systemName: "doc.on.doc")) { _ in + self.didPressCopy(for: message) + }) + + // Copy Selection + actions.append(UIAction(title: NSLocalizedString("Copy message selection", comment: ""), image: .init(systemName: "text.viewfinder")) { _ in + self.didPressCopySelection(for: message) + }) + + // Reschedule option + actions.append(UIAction(title: NSLocalizedString("Reschedule", comment: "Reschedule a message that is send later"), image: .init(systemName: "calendar.badge.clock")) { _ in + Task { + await self.didPressReschedule(for: message, at: indexPath) + } + }) + + // Send now option + actions.append(UIAction(title: NSLocalizedString("Send now", comment: "Send a message now"), image: .init(systemName: "paperplane")) { _ in + self.didPressSendNow(for: message, at: indexPath) + }) + + var destructiveMenuActions: [UIMenuElement] = [] + + // Edit option + destructiveMenuActions.append(UIAction(title: NSLocalizedString("Edit", comment: "Edit a message or room participants"), image: .init(systemName: "pencil")) { _ in + self.setTextInputbarHidden(false, animated: true) + self.textView.layer.cornerRadius = self.textView.frame.size.height / 2 + self.didPressEdit(for: message) + }) + + // Delete option + destructiveMenuActions.append(UIAction(title: NSLocalizedString("Delete", comment: ""), image: .init(systemName: "trash"), attributes: .destructive) { _ in + Task { + await self.didPressDeleteScheduled(for: message, at: indexPath) + } + }) + + 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 + } +} diff --git a/NextcloudTalk/Database/NCDatabaseManager.h b/NextcloudTalk/Database/NCDatabaseManager.h index 6ffc9fe83..652030687 100644 --- a/NextcloudTalk/Database/NCDatabaseManager.h +++ b/NextcloudTalk/Database/NCDatabaseManager.h @@ -86,6 +86,7 @@ extern NSString * const kCapabilityImportantConversations; extern NSString * const kCapabilitySensitiveConversations; extern NSString * const kCapabilityThreads; extern NSString * const kCapabilityPinnedMessages; +extern NSString * const kCapabilityScheduleMessages; extern NSString * const kNotificationsCapabilityExists; extern NSString * const kNotificationsCapabilityTestPush; diff --git a/NextcloudTalk/Database/NCDatabaseManager.m b/NextcloudTalk/Database/NCDatabaseManager.m index bd72e5e43..a73cf5c23 100644 --- a/NextcloudTalk/Database/NCDatabaseManager.m +++ b/NextcloudTalk/Database/NCDatabaseManager.m @@ -16,7 +16,7 @@ NSString *const kTalkDatabaseFolder = @"Library/Application Support/Talk"; NSString *const kTalkDatabaseFileName = @"talk.realm"; -uint64_t const kTalkDatabaseSchemaVersion = 84; +uint64_t const kTalkDatabaseSchemaVersion = 85; NSString * const kCapabilitySystemMessages = @"system-messages"; NSString * const kCapabilityNotificationLevels = @"notification-levels"; @@ -87,6 +87,7 @@ NSString * const kCapabilitySensitiveConversations = @"sensitive-conversations"; NSString * const kCapabilityThreads = @"threads"; NSString * const kCapabilityPinnedMessages = @"pinned-messages"; +NSString * const kCapabilityScheduleMessages = @"scheduled-messages"; NSString * const kNotificationsCapabilityExists = @"exists"; NSString * const kNotificationsCapabilityTestPush = @"test-push"; diff --git a/NextcloudTalk/Images.xcassets/custom.paperplane.badge.clock.symbolset/Contents.json b/NextcloudTalk/Images.xcassets/custom.paperplane.badge.clock.symbolset/Contents.json new file mode 100644 index 000000000..78be1c21b --- /dev/null +++ b/NextcloudTalk/Images.xcassets/custom.paperplane.badge.clock.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "custom.paperplane.badge.clock.svg", + "idiom" : "universal" + } + ] +} diff --git a/NextcloudTalk/Images.xcassets/custom.paperplane.badge.clock.symbolset/custom.paperplane.badge.clock.svg b/NextcloudTalk/Images.xcassets/custom.paperplane.badge.clock.symbolset/custom.paperplane.badge.clock.svg new file mode 100644 index 000000000..d1024e579 --- /dev/null +++ b/NextcloudTalk/Images.xcassets/custom.paperplane.badge.clock.symbolset/custom.paperplane.badge.clock.svg @@ -0,0 +1,121 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.7.0 + Requires Xcode 17 or greater + Generated from + Typeset at 100.0 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/NextcloudTalk/Network/NCAPIControllerExtensions.swift b/NextcloudTalk/Network/NCAPIControllerExtensions.swift index a250b9a62..543093473 100644 --- a/NextcloudTalk/Network/NCAPIControllerExtensions.swift +++ b/NextcloudTalk/Network/NCAPIControllerExtensions.swift @@ -1389,4 +1389,82 @@ import NextcloudKit completionBlock(nil) } } + + // MARK: - Message scheduling + + @MainActor + @nonobjc + public func getScheduledMessages(forRoom token: String, forAccount account: TalkAccount) async throws -> [ScheduledMessage] { + guard let apiSessionManager = self.apiSessionManagers.object(forKey: account.accountId) as? NCAPISessionManager, + let encodedToken = token.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) + else { throw ApiControllerError.preconditionError } + + let apiVersion = self.chatAPIVersion(for: account) + let urlString = self.getRequestURL(forEndpoint: "chat/\(encodedToken)/schedule", withAPIVersion: apiVersion, for: account) + + let ocsResponse = try await apiSessionManager.getOcs(urlString, account: account) + + guard let dataArrayDict = ocsResponse.dataArrayDict else { throw ApiControllerError.unexpectedOcsResponse } + + return dataArrayDict.compactMap { ScheduledMessage(dictionary: $0, withAccount: account) } + } + + @MainActor + @discardableResult + @nonobjc + public func scheduleMessage(_ message: String, inRoom token: String, sendAt: Int, replyTo: Int? = nil, silent: Bool? = nil, threadTitle: String? = nil, threadId: Int? = nil, forAccount account: TalkAccount) async throws -> ScheduledMessage? { + guard let apiSessionManager = self.apiSessionManagers.object(forKey: account.accountId) as? NCAPISessionManager, + let encodedToken = token.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) + else { throw ApiControllerError.preconditionError } + + let apiVersion = self.chatAPIVersion(for: account) + let urlString = self.getRequestURL(forEndpoint: "chat/\(encodedToken)/schedule", withAPIVersion: apiVersion, for: account) + + let parameters: [String: Any?] = [ + "message": message, + "sendAt": sendAt, + "replyTo": replyTo, + "silent": silent, + "threadTitle": threadTitle, + "threadId": threadId + ] + + let result = try await apiSessionManager.postOcs(urlString, account: account, parameters: parameters.compactMapValues { $0 }) + return ScheduledMessage(dictionary: result.dataDict, withAccount: account) + } + + @MainActor + @nonobjc + public func editScheduledMessage(_ messageId: String, withMessage message: String, inRoom token: String, sendAt: Int, silent: Bool? = nil, threadTitle: String? = nil, forAccount account: TalkAccount) async throws -> ScheduledMessage { + guard let apiSessionManager = self.apiSessionManagers.object(forKey: account.accountId) as? NCAPISessionManager, + let encodedToken = token.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) + else { throw ApiControllerError.preconditionError } + + let apiVersion = self.chatAPIVersion(for: account) + let urlString = self.getRequestURL(forEndpoint: "chat/\(encodedToken)/schedule/\(messageId)", withAPIVersion: apiVersion, for: account) + + let parameters: [String: Any?] = [ + "message": message, + "sendAt": sendAt, + "silent": silent, + "threadTitle": threadTitle + ] + + let result = try await apiSessionManager.postOcs(urlString, account: account, parameters: parameters.compactMapValues { $0 }) + guard let updatedMessage = ScheduledMessage(dictionary: result.dataDict, withAccount: account) else { throw ApiControllerError.unexpectedOcsResponse } + + return updatedMessage + } + + @MainActor + public func deleteScheduledMessage(_ messageId: String, inRoom token: String, forAccount account: TalkAccount) async throws { + guard let apiSessionManager = self.apiSessionManagers.object(forKey: account.accountId) as? NCAPISessionManager, + let encodedToken = token.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) + else { throw ApiControllerError.preconditionError } + + let apiVersion = self.chatAPIVersion(for: account) + let urlString = self.getRequestURL(forEndpoint: "chat/\(encodedToken)/schedule/\(messageId)", withAPIVersion: apiVersion, for: account) + + try await apiSessionManager.deleteOcs(urlString, account: account) + } } diff --git a/NextcloudTalk/Rooms/NCRoom.h b/NextcloudTalk/Rooms/NCRoom.h index eef6da670..0d9fa6f78 100644 --- a/NextcloudTalk/Rooms/NCRoom.h +++ b/NextcloudTalk/Rooms/NCRoom.h @@ -149,6 +149,7 @@ extern NSString * const NCRoomObjectTypeExtendedConversation; @property (nonatomic, assign) BOOL isSensitive; @property (nonatomic, assign) NSInteger lastPinnedId; @property (nonatomic, assign) NSInteger hiddenPinnedId; +@property (nonatomic, assign) BOOL hasScheduledMessages; + (instancetype _Nullable)roomWithDictionary:(NSDictionary * _Nullable)roomDict andAccountId:(NSString * _Nullable)accountId; + (void)updateRoom:(NCRoom * _Nonnull)managedRoom withRoom:(NCRoom * _Nonnull)room; diff --git a/NextcloudTalk/Rooms/NCRoom.m b/NextcloudTalk/Rooms/NCRoom.m index cda69a9d3..a6bdcb093 100644 --- a/NextcloudTalk/Rooms/NCRoom.m +++ b/NextcloudTalk/Rooms/NCRoom.m @@ -71,6 +71,7 @@ + (instancetype)roomWithDictionary:(NSDictionary *)roomDict andAccountId:(NSStri room.isSensitive = [[roomDict objectForKey:@"isSensitive"] boolValue]; room.lastPinnedId = [[roomDict objectForKey:@"lastPinnedId"] integerValue]; room.hiddenPinnedId = [[roomDict objectForKey:@"hiddenPinnedId"] integerValue]; + room.hasScheduledMessages = [[roomDict objectForKey:@"hasScheduledMessages"] boolValue]; // Local-only field -> update only if there's actually a value if ([roomDict objectForKey:@"pendingMessage"] != nil) { @@ -189,6 +190,7 @@ + (void)updateRoom:(NCRoom *)managedRoom withRoom:(NCRoom *)room managedRoom.isSensitive = room.isSensitive; managedRoom.lastPinnedId = room.lastPinnedId; managedRoom.hiddenPinnedId = room.hiddenPinnedId; + managedRoom.hasScheduledMessages = room.hasScheduledMessages; } + (NSString *)primaryKey { diff --git a/NextcloudTalk/User Interface/DatePickerTextField.swift b/NextcloudTalk/User Interface/DatePickerTextField.swift index 8546ca8be..916efdb76 100644 --- a/NextcloudTalk/User Interface/DatePickerTextField.swift +++ b/NextcloudTalk/User Interface/DatePickerTextField.swift @@ -115,4 +115,12 @@ struct DatePickerTextFieldWrapper: UIViewRepresentable { self.completion = completion self.becomeFirstResponder() } + + public func getDate() async -> (DatePickerTextFieldButtonTapped, Date?) { + return await withCheckedContinuation { continuation in + self.getDate { buttonTapped, selectedDate in + continuation.resume(returning: (buttonTapped, selectedDate)) + } + } + } } diff --git a/NextcloudTalk/en.lproj/Localizable.strings b/NextcloudTalk/en.lproj/Localizable.strings index a59518221..ab2d7de02 100644 --- a/NextcloudTalk/en.lproj/Localizable.strings +++ b/NextcloudTalk/en.lproj/Localizable.strings @@ -1406,6 +1406,9 @@ /* No comment provided by engineer. */ "Message deleted successfully, but Matterbridge is configured and the message might already be distributed to other services" = "Message deleted successfully, but Matterbridge is configured and the message might already be distributed to other services"; +/* No comment provided by engineer. */ +"Message editing failed" = "Message editing failed"; + /* No comment provided by engineer. */ "Message expiration" = "Message expiration"; @@ -1421,6 +1424,21 @@ /* No comment provided by engineer. */ "Message preview will be disabled in conversation list and notifications" = "Message preview will be disabled in conversation list and notifications"; +/* No comment provided by engineer. */ +"Message rescheduling failed" = "Message rescheduling failed"; + +/* No comment provided by engineer. */ +"Message scheduling failed" = "Message scheduling failed"; + +/* No comment provided by engineer. */ +"Message successfully edited" = "Message successfully edited"; + +/* No comment provided by engineer. */ +"Message successfully rescheduled" = "Message successfully rescheduled"; + +/* No comment provided by engineer. */ +"Message successfully scheduled" = "Message successfully scheduled"; + /* No comment provided by engineer. */ "Message unpinned" = "Message unpinned"; @@ -1862,6 +1880,9 @@ /* Name of a repository */ "Repo" = "Repo"; +/* Reschedule a message that is send later */ +"Reschedule" = "Reschedule"; + /* No comment provided by engineer. */ "Resend" = "Resend"; @@ -1904,6 +1925,9 @@ /* No comment provided by engineer. */ "Schedule a meeting" = "Schedule a meeting"; +/* No comment provided by engineer. */ +"Scheduled messages" = "Scheduled messages"; + /* No comment provided by engineer. */ "Screensharing stopped" = "Screensharing stopped"; @@ -1931,9 +1955,18 @@ /* No comment provided by engineer. */ "Send call notification" = "Send call notification"; +/* No comment provided by engineer. */ +"Send later" = "Send later"; + +/* No comment provided by engineer. */ +"Send later without notification" = "Send later without notification"; + /* No comment provided by engineer. */ "Send message" = "Send message"; +/* Send a message now */ +"Send now" = "Send now"; + /* No comment provided by engineer. */ "Send without notification" = "Send without notification"; diff --git a/NextcloudTalkTests/Integration/IntegrationChatTest.swift b/NextcloudTalkTests/Integration/IntegrationChatTest.swift index 910d67533..683df6f49 100644 --- a/NextcloudTalkTests/Integration/IntegrationChatTest.swift +++ b/NextcloudTalkTests/Integration/IntegrationChatTest.swift @@ -45,4 +45,48 @@ final class IntegrationChatTest: TestBase { XCTAssertEqual(try XCTUnwrap(updatedRoom).lastPinnedId, 0) } + func testScheduleMessages() async throws { + try skipWithoutCapability(capability: kCapabilityScheduleMessages) + + let activeAccount = NCDatabaseManager.sharedInstance().activeAccount() + let chatMessage = "Scheduled Message 😀😆" + let chatMessageEdited = "Scheduled Message 😀😆 Edited" + + let room = try await createUniqueRoom(prefix: "Schedule message room", withAccount: activeAccount) + + // Ensure no message was scheduled in hte room + var scheduledMessages = try await NCAPIController.sharedInstance().getScheduledMessages(forRoom: room.token, forAccount: activeAccount) + XCTAssertEqual(scheduledMessages.count, 0) + + // Schedule our first message + let timestamp = Int(Date().timeIntervalSince1970 + 300) + var message = try await NCAPIController.sharedInstance().scheduleMessage(chatMessage, inRoom: room.token, sendAt: timestamp, forAccount: activeAccount) + XCTAssertNotNil(message) + + // Check if we can retrieve the scheduled message + scheduledMessages = try await NCAPIController.sharedInstance().getScheduledMessages(forRoom: room.token, forAccount: activeAccount) + XCTAssertEqual(scheduledMessages.count, 1) + let firstMessage = scheduledMessages.first! + XCTAssertEqual(firstMessage.message, chatMessage) + XCTAssertEqual(firstMessage.sendAtTimestamp, timestamp) + XCTAssertEqual(firstMessage.id, message?.id) + + // Edit the scheduled message + XCTAssertNotNil(firstMessage.id) + message = try await NCAPIController.sharedInstance().editScheduledMessage(firstMessage.id, withMessage: chatMessageEdited, inRoom: room.token, sendAt: timestamp, forAccount: activeAccount) + + // Check if we can retrieve the edited scheduled message + scheduledMessages = try await NCAPIController.sharedInstance().getScheduledMessages(forRoom: room.token, forAccount: activeAccount) + XCTAssertEqual(scheduledMessages.count, 1) + XCTAssertEqual(scheduledMessages.first?.message, chatMessageEdited) + XCTAssertEqual(scheduledMessages.first?.id, message?.id) + + // Delete the scheduled message + try await NCAPIController.sharedInstance().deleteScheduledMessage(firstMessage.id, inRoom: room.token, forAccount: activeAccount) + + // No scheduled messages should be there anymore + scheduledMessages = try await NCAPIController.sharedInstance().getScheduledMessages(forRoom: room.token, forAccount: activeAccount) + XCTAssertEqual(scheduledMessages.count, 0) + } + }