diff --git a/NextcloudTalk.xcodeproj/project.pbxproj b/NextcloudTalk.xcodeproj/project.pbxproj index 9c598f929..e19a17510 100644 --- a/NextcloudTalk.xcodeproj/project.pbxproj +++ b/NextcloudTalk.xcodeproj/project.pbxproj @@ -562,6 +562,7 @@ "Chat upload/ChatFileUploadError.swift", "Chat upload/ChatFileUploadMetadata.swift", "Chat upload/ChatImageCompressor.swift", + "Chat upload/FileUploadReference.swift", "Chat views/NCChatTitleView.swift", "Chat views/NCChatTitleView.xib", "Chat views/NCMessageTextView.swift", diff --git a/NextcloudTalk/Chat/BaseChatViewController.swift b/NextcloudTalk/Chat/BaseChatViewController.swift index 6fc3dd74e..d1f20d25b 100644 --- a/NextcloudTalk/Chat/BaseChatViewController.swift +++ b/NextcloudTalk/Chat/BaseChatViewController.swift @@ -117,6 +117,12 @@ import Toast private var messageHeightCache = NCChatMessageHeightCache() + /// The files of one upload, shown as a single message, by the id of the message they are shown as + internal var fileMessageGroups: [Int: FileMessageGroup] = [:] + + /// Messages shown as part of the group of their upload instead of on their own + internal var messageIdsHiddenInFileGroups: Set = [] + private lazy var inputbarBorderView: UIView = { let inputbarBorderView = UIView() inputbarBorderView.autoresizingMask = [.flexibleWidth, .flexibleBottomMargin] @@ -280,6 +286,9 @@ import Toast self.tableView?.register(UINib(nibName: "BaseChatTableViewCell", bundle: nil), forCellReuseIdentifier: chatGroupedMessageCellIdentifier) self.tableView?.register(UINib(nibName: "BaseChatTableViewCell", bundle: nil), forCellReuseIdentifier: chatReplyMessageCellIdentifier) + self.tableView?.register(UINib(nibName: "BaseChatTableViewCell", bundle: nil), forCellReuseIdentifier: fileGroupMessageCellIdentifier) + self.tableView?.register(UINib(nibName: "BaseChatTableViewCell", bundle: nil), forCellReuseIdentifier: fileGroupGroupedMessageCellIdentifier) + self.tableView?.register(UINib(nibName: "BaseChatTableViewCell", bundle: nil), forCellReuseIdentifier: fileMessageCellIdentifier) self.tableView?.register(UINib(nibName: "BaseChatTableViewCell", bundle: nil), forCellReuseIdentifier: fileGroupedMessageCellIdentifier) @@ -688,6 +697,7 @@ import Toast updatedMessage.isGroupMessage = message.isGroupMessage && message.actorType != "bots" && updatedMessage.lastEditTimestamp == 0 updatedMessage.copyPendingReactions(from: message) self.messages[keyDate]?[indexPath.row] = updatedMessage + self.regroupFileMessages() // Check if there are any messages that reference our message as a parent -> these need to be reloaded as well if let visibleIndexPaths = self.tableView?.indexPathsForVisibleRows { @@ -2637,9 +2647,13 @@ import Toast } } + self.regroupFileMessages() + return lastHistoryMessageIP } + self.regroupFileMessages() + return nil } @@ -2685,6 +2699,7 @@ import Toast } self.sortDateSections() + self.regroupFileMessages() } func appendMessages(messages: [NCChatMessage]) { @@ -2692,6 +2707,7 @@ import Toast // Therefore we wrap it in this append function self.internalAppendMessages(messages: messages, inDictionary: &self.messages) self.sortDateSections() + self.regroupFileMessages() } private func internalAppendMessages(messages: [NCChatMessage], inDictionary dictionary: inout [Date: [NCChatMessage]]) { @@ -2781,6 +2797,8 @@ import Toast self.tableView?.endUpdates() } } + + self.regroupFileMessages() } } @@ -2788,6 +2806,103 @@ import Toast self.dateSections = self.messages.keys.sorted() } + // MARK: - Grouping of files shared as one upload + + /// The width the body of a message has, which is what the previews of a group have to share. + /// + /// Measuring a group and building it have to agree on this, or the cell is a different height + /// than the previews it shows. Heights are measured and cached against a row width, so take + /// that width rather than asking the table view, which has already changed on a rotation. + internal func availableBodyWidth(forRowWidth rowWidth: CGFloat, isOwnMessage: Bool) -> CGFloat { + let bodyWidth = BaseChatTableViewCell.bodyWidth(forRowWidth: rowWidth, isOwnMessage: isOwnMessage) + + return max(0, bodyWidth - BaseChatTableViewCell.bubbleWidthSafetyMargin) + } + + /// The same, for the width the rows of the chat currently have. + internal func availableBodyWidth(forOwnMessage isOwnMessage: Bool) -> CGFloat { + guard let tableView = self.tableView else { return 0 } + + var rowWidth = tableView.frame.width - chatMessageCellAvatarHeight + rowWidth -= tableView.safeAreaInsets.left + tableView.safeAreaInsets.right + + return self.availableBodyWidth(forRowWidth: rowWidth, isOwnMessage: isOwnMessage) + } + + /// The group a message is shown as, when it is the one its upload is shown as, or a group of + /// one for a file that is drawn on a card without belonging to an upload. + internal func fileMessageGroup(showing message: NCChatMessage) -> FileMessageGroup? { + if message.messageId > 0, let group = self.fileMessageGroups[message.messageId] { + return group + } + + return message.isFileCardMessage ? FileMessageGroup(messages: [message]) : nil + } + + /// Whether a message is shown as part of the group of its upload rather than on its own. + internal func isHiddenInFileMessageGroup(_ message: NCChatMessage) -> Bool { + guard message.messageId > 0 else { return false } + + return self.messageIdsHiddenInFileGroups.contains(message.messageId) + } + + /// Works out which files are shown together, over the messages currently loaded. + /// + /// Runs after every change to the data source instead of while messages are added, because a + /// group is not something a message can decide on its own: files of one upload can arrive in + /// separate batches, history can be prepended in front of a group, and removing a message can + /// join or split one. + internal func regroupFileMessages() { + var groups: [Int: FileMessageGroup] = [:] + var hiddenMessageIds: Set = [] + + for dateSection in self.dateSections { + guard let messagesForDate = self.messages[dateSection] else { continue } + + for group in FileMessageGroup.groups(in: messagesForDate) { + groups[group.anchor.messageId] = group + + for message in group.messages where message.messageId != group.anchor.messageId { + hiddenMessageIds.insert(message.messageId) + } + } + } + + self.invalidateHeights(previousGroups: self.fileMessageGroups, + groups: groups, + previousHiddenMessageIds: self.messageIdsHiddenInFileGroups, + hiddenMessageIds: hiddenMessageIds) + + self.fileMessageGroups = groups + self.messageIdsHiddenInFileGroups = hiddenMessageIds + } + + /// A message that joined or left a group, or whose group gained or lost a file, is a different + /// height than it was measured at. + private func invalidateHeights(previousGroups: [Int: FileMessageGroup], + groups: [Int: FileMessageGroup], + previousHiddenMessageIds: Set, + hiddenMessageIds: Set) { + var changedMessages: [NCChatMessage] = [] + + for anchorId in Set(previousGroups.keys).union(groups.keys) + where previousGroups[anchorId]?.messages.count != groups[anchorId]?.messages.count { + if let message = groups[anchorId]?.anchor ?? previousGroups[anchorId]?.anchor { + changedMessages.append(message) + } + } + + for messageId in previousHiddenMessageIds.symmetricDifference(hiddenMessageIds) { + if let message = self.indexPathAndMessage(forMessageId: messageId)?.message { + changedMessages.append(message) + } + } + + for message in changedMessages { + self.messageHeightCache.removeHeight(forMessage: message) + } + } + // MARK: - Message grouping func shouldGroupMessage(newMessage: NCChatMessage, withMessage lastMessage: NCChatMessage) -> Bool { @@ -3360,6 +3475,19 @@ import Toast } } + if let fileGroup = self.fileMessageGroup(showing: message) { + let cellIdentifier = message.isGroupMessage ? fileGroupGroupedMessageCellIdentifier : fileGroupMessageCellIdentifier + + if let cell = self.tableView?.dequeueReusableCell(withIdentifier: cellIdentifier) as? BaseChatTableViewCell { + cell.delegate = self + cell.fileGroup = fileGroup + cell.availableBodyWidth = self.availableBodyWidth(forOwnMessage: message.isMessage(from: self.account.userId)) + cell.setup(for: message, inRoom: self.room, forThread: self.thread, withAccount: self.account) + + return cell + } + } + if message.file() != nil { let cellIdentifier = message.isGroupMessage ? fileGroupedMessageCellIdentifier : fileMessageCellIdentifier @@ -3457,6 +3585,11 @@ import Toast return 0.0 } + // Shown as part of the group of its upload, not on its own + if self.isHiddenInFileMessageGroup(message) { + return 0.0 + } + // Chat messages let isOwnMessage = message.isMessage(from: self.account.userId) let messageString = message.parsedMarkdownForChat() ?? NSMutableAttributedString() @@ -3466,19 +3599,10 @@ import Toast // 4 * right(10) + dateLabel(40) width -= 80.0 } else { - // Avatar is already subtracted, but we need to take padding of left(10) into account - width -= 10.0 + width = BaseChatTableViewCell.bodyWidth(forRowWidth: width, isOwnMessage: isOwnMessage) // MessageTextView has padding of 2*10 width -= 20.0 - - if isOwnMessage { - // For own messages we have a padding of 40 to the avatar view and 10 to the right superview - width -= 50.0 - } else { - // For others messages, we have a padding of 10 to the avatar view und 64 to the right superview - width -= 74.0 - } } self.textViewForSizing.attributedText = messageString @@ -3527,8 +3651,25 @@ import Toast height += 70 // quoteView(70) } + if let fileGroup = self.fileMessageGroup(showing: message) { + let files = fileGroup.messagesInUploadOrder.compactMap { $0.file() } + + height += GroupedFilePreviewView.Layout(files: files, availableWidth: self.availableBodyWidth(forRowWidth: originalWidth, isOwnMessage: isOwnMessage)).height + + if message.sharesFileWithoutCaption { + // A group shows its caption, never a file name, so a measured name takes no space + // here. An empty text was already subtracted above. + if !messageString.string.isEmpty { + height -= ceil(bodyBounds.height) + } + } else { + // Only a caption is separated from the previews. Without one there is no text view + // in the layout to leave room for. + height += 10 + } + // Voice message should be before message.file check since it contains a file - if message.isVoiceMessage { + } else if message.isVoiceMessage { height -= ceil(bodyBounds.height) height += voiceMessageCellPlayerHeight @@ -3660,6 +3801,8 @@ import Toast else { return nil } previewCell.frame = .init(origin: .zero, size: tableView.rectForRow(at: indexPath).size) + previewCell.fileGroup = self.fileMessageGroup(showing: message) + previewCell.availableBodyWidth = self.availableBodyWidth(forOwnMessage: message.isMessage(from: self.account.userId)) previewCell.setup(for: message, inRoom: self.room, forThread: self.thread, withAccount: self.account) previewCell.layoutIfNeeded() diff --git a/NextcloudTalk/Chat/Chat cells/BaseChatTableViewCell+FileGroup.swift b/NextcloudTalk/Chat/Chat cells/BaseChatTableViewCell+FileGroup.swift new file mode 100644 index 000000000..2b665e5a9 --- /dev/null +++ b/NextcloudTalk/Chat/Chat cells/BaseChatTableViewCell+FileGroup.swift @@ -0,0 +1,86 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import UIKit + +extension BaseChatTableViewCell { + + func setupForFileGroupCell(with group: FileMessageGroup, with account: TalkAccount) { + if self.groupedFilePreviewView == nil { + let groupedFilePreviewView = GroupedFilePreviewView() + self.groupedFilePreviewView = groupedFilePreviewView + + groupedFilePreviewView.translatesAutoresizingMaskIntoConstraints = false + groupedFilePreviewView.delegate = self + groupedFilePreviewView.accessibilityIdentifier = "groupedFilePreviewView" + + self.messageBodyView.addSubview(groupedFilePreviewView) + + let messageTextView = MessageBodyTextView() + self.messageTextView = messageTextView + + messageTextView.translatesAutoresizingMaskIntoConstraints = false + + self.messageBodyView.addSubview(messageTextView) + + // Without a caption the text view is taken out of the layout entirely. Hiding it is not + // enough: an empty text view still takes its line height, which would make the body + // taller than the cell was measured to be, and everything below it stops taking taps. + self.fileGroupCaptionConstraints = [ + messageTextView.topAnchor.constraint(equalTo: groupedFilePreviewView.bottomAnchor, constant: 10), + messageTextView.bottomAnchor.constraint(equalTo: self.messageBodyView.bottomAnchor) + ] + + self.fileGroupWithoutCaptionConstraint = groupedFilePreviewView.bottomAnchor.constraint(equalTo: self.messageBodyView.bottomAnchor) + + NSLayoutConstraint.activate([ + groupedFilePreviewView.leftAnchor.constraint(equalTo: self.messageBodyView.leftAnchor), + groupedFilePreviewView.topAnchor.constraint(equalTo: self.messageBodyView.topAnchor), + groupedFilePreviewView.rightAnchor.constraint(lessThanOrEqualTo: self.messageBodyView.rightAnchor), + messageTextView.leftAnchor.constraint(equalTo: self.messageBodyView.leftAnchor), + messageTextView.rightAnchor.constraint(equalTo: self.messageBodyView.rightAnchor) + ]) + } + + guard let groupedFilePreviewView = self.groupedFilePreviewView, + let messageTextView = self.messageTextView + else { return } + + groupedFilePreviewView.setup(with: group, account: account, availableWidth: self.availableBodyWidth) + + // The caption is carried by the file shared last, which is what the group is shown as + let hasCaption = !group.anchor.sharesFileWithoutCaption + + messageTextView.isHidden = !hasCaption + messageTextView.attributedText = hasCaption ? group.anchor.parsedMarkdownForChat() : nil + messageTextView.dataDetectorTypes = hasCaption ? .all : [] + + self.fileGroupWithoutCaptionConstraint?.isActive = false + NSLayoutConstraint.deactivate(self.fileGroupCaptionConstraints) + + if hasCaption { + NSLayoutConstraint.activate(self.fileGroupCaptionConstraints) + } else { + self.fileGroupWithoutCaptionConstraint?.isActive = true + } + } + + func prepareForReuseFileGroupCell() { + self.groupedFilePreviewView?.prepareForReuse() + } +} + +extension BaseChatTableViewCell: GroupedFilePreviewViewDelegate { + + func groupedFilePreviewView(_ view: GroupedFilePreviewView, didSelectFileAt index: Int) { + guard let messages = self.fileGroup?.messagesInUploadOrder, index < messages.count else { return } + + let message = messages[index] + + guard let file = message.file(), file.path != nil else { return } + + self.delegate?.cellWants(toDownloadFile: file, for: message) + } +} diff --git a/NextcloudTalk/Chat/Chat cells/BaseChatTableViewCell.swift b/NextcloudTalk/Chat/Chat cells/BaseChatTableViewCell.swift index 425593e10..dbffdf86d 100644 --- a/NextcloudTalk/Chat/Chat cells/BaseChatTableViewCell.swift +++ b/NextcloudTalk/Chat/Chat cells/BaseChatTableViewCell.swift @@ -28,6 +28,20 @@ protocol BaseChatTableViewCellDelegate: AnyObject { } // Common elements + +/// A card drawn inside a bubble, for a link preview or a file of a group. +/// +/// A filled card instead of a hairline border, which used the very same translucent colour and so +/// would have doubled up. White in both appearances, so the card reads as a panel *lighter* than the +/// bubble - the semantic fills darken instead. Light mode needs the higher alpha, starting lighter. +public let chatBubbleCardFill = UIColor { traitCollection in + let alpha = traitCollection.userInterfaceStyle == .dark ? 0.10 : 0.65 + + return UIColor.white.withAlphaComponent(alpha) +} + +public let chatBubbleCardCornerRadius = 8.0 + public let chatMessageCellPreviewCornerRadius = 4.0 public let chatMessageCellAvatarHeight = 30.0 @@ -38,6 +52,10 @@ public let chatReplyMessageCellIdentifier = "chatReplyMessageCellIdentifier" public let chatMessageCellMinimumHeight = 45.0 public let chatGroupedMessageCellMinimumHeight = 25.0 +// Grouped file cell (the files of one upload, shown as a single message) +public let fileGroupMessageCellIdentifier = "fileGroupMessageCellIdentifier" +public let fileGroupGroupedMessageCellIdentifier = "fileGroupGroupedMessageCellIdentifier" + // File cell public let fileMessageCellIdentifier = "fileMessageCellIdentifier" public let fileGroupedMessageCellIdentifier = "fileGroupedMessageCellIdentifier" @@ -92,6 +110,31 @@ class BaseChatTableViewCell: UITableViewCell, AudioPlayerViewDelegate, Reactions @IBOutlet weak var bubbleView: UIView! @IBOutlet weak var bubbleStackView: UIStackView! + /// What the bubble keeps between itself and the avatar, on top of the paddings below + static let bubbleInsetToAvatar = 10.0 + + /// 40 to the avatar view and 10 to the superview, see `rightBubbleConstraints` + static let ownMessageBubbleInsets = 50.0 + + /// 10 to the avatar view and 64 to the superview, see `leftBubbleConstraints` + static let otherMessageBubbleInsets = 74.0 + + /// The bubble ends up a little narrower again than its constraints suggest. Erring on the small + /// side costs a few points of preview width, while erring the other way puts previews past the + /// bubble, where they are clipped and stop taking taps. + static let bubbleWidthSafetyMargin = 10.0 + + /// The width the body of a message has, from the width its row has. + /// + /// The chat view measures a message before there is a cell to measure, so this has to be + /// answerable without one. It lives here so that it is maintained together with the bubble + /// constraints below, which are where the paddings come from. + static func bodyWidth(forRowWidth rowWidth: CGFloat, isOwnMessage: Bool) -> CGFloat { + let bubbleInsets = isOwnMessage ? self.ownMessageBubbleInsets : self.otherMessageBubbleInsets + + return max(0, rowWidth - self.bubbleInsetToAvatar - bubbleInsets) + } + // Since we use different relations depending on the bubble (other user or app user) we setup // the constraints programmatically instead of in interface builder lazy var leftBubbleConstraints = { @@ -149,6 +192,17 @@ class BaseChatTableViewCell: UITableViewCell, AudioPlayerViewDelegate, Reactions // Message cell internal var messageTextView: MessageBodyTextView? + /// The files of one upload, when this cell shows a group of them instead of a single message + public var fileGroup: FileMessageGroup? + + internal var groupedFilePreviewView: GroupedFilePreviewView? + + /// The width the message body has, which the previews of a group have to share + public var availableBodyWidth: CGFloat = 0 + + internal var fileGroupCaptionConstraints: [NSLayoutConstraint] = [] + internal var fileGroupWithoutCaptionConstraint: NSLayoutConstraint? + // File cell internal var filePreviewImageView: UIImageView? internal var filePreviewImageViewHeightConstraint: NSLayoutConstraint? @@ -223,6 +277,9 @@ class BaseChatTableViewCell: UITableViewCell, AudioPlayerViewDelegate, Reactions self.referenceView?.prepareForReuse() + self.fileGroup = nil + + self.prepareForReuseFileGroupCell() self.prepareForReuseFileCell() self.prepareForReuseLocationCell() self.prepareForReuseAudioCell() @@ -390,7 +447,10 @@ class BaseChatTableViewCell: UITableViewCell, AudioPlayerViewDelegate, Reactions self.addSlideToReplyGestureRecognizer(for: message) } - if message.isVoiceMessage { + if let fileGroup = self.fileGroup { + // The files of one upload, shown as a single message + self.setupForFileGroupCell(with: fileGroup, with: account) + } else if message.isVoiceMessage { // Audio message self.setupForAudioCell(with: message) } else if message.poll != nil { @@ -771,6 +831,12 @@ class BaseChatTableViewCell: UITableViewCell, AudioPlayerViewDelegate, Reactions @objc func didChangeIsDownloading(notification: Notification) { DispatchQueue.main.async { + // A group has a file per row, each of which can be downloading on its own + if let groupedFilePreviewView = self.groupedFilePreviewView, self.fileGroup != nil { + groupedFilePreviewView.updateDownloadStatus(from: notification) + return + } + // Make sure this notification is really for this cell guard let fileParameter = self.message?.file(), let receivedStatus = NCChatFileStatus.getStatus(from: notification, for: fileParameter) @@ -787,6 +853,11 @@ class BaseChatTableViewCell: UITableViewCell, AudioPlayerViewDelegate, Reactions @objc func didChangeDownloadProgress(notification: Notification) { DispatchQueue.main.async { + if let groupedFilePreviewView = self.groupedFilePreviewView, self.fileGroup != nil { + groupedFilePreviewView.updateDownloadStatus(from: notification) + return + } + // Make sure this notification is really for this cell guard let fileParameter = self.message?.file(), let receivedStatus = NCChatFileStatus.getStatus(from: notification, for: fileParameter) diff --git a/NextcloudTalk/Chat/Chat cells/GroupedFilePreviewTileView.swift b/NextcloudTalk/Chat/Chat cells/GroupedFilePreviewTileView.swift new file mode 100644 index 000000000..464a175f9 --- /dev/null +++ b/NextcloudTalk/Chat/Chat cells/GroupedFilePreviewTileView.swift @@ -0,0 +1,310 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import UIKit + +extension NCMessageFileParameter { + + /// Whether the file is shown as a preview tile rather than as a row with its name. + /// + /// Only media the server can render a preview of: everything else would be a tile showing the + /// same generic icon, which says less than the file name does. + var isPreviewableMedia: Bool { + guard let mimetype = self.mimetype, self.previewAvailable else { return false } + + return NCUtils.isImage(fileType: mimetype) || NCUtils.isVideo(fileType: mimetype) + } + + /// The extension and the size of the file, as shown next to its name in a group. + var shortDescription: String { + let fileExtension = (self.name as NSString?)?.pathExtension.uppercased() ?? "" + let size = self.size ?? 0 + let formattedSize = size > 0 ? ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file) : "" + + return [fileExtension, formattedSize].filter { !$0.isEmpty }.joined(separator: " · ") + } +} + +/// One media file of a group, shown as a square preview. +class GroupedFilePreviewTileView: UIControl { + + private lazy var previewImageView: FilePreviewImageView = { + let imageView = FilePreviewImageView() + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.layer.cornerRadius = chatMessageCellPreviewCornerRadius + imageView.backgroundColor = .secondarySystemFill + return imageView + }() + + private lazy var playIconImageView: UIImageView = { + let configuration = UIImage.SymbolConfiguration(paletteColors: [UIColor.white.withAlphaComponent(0.8), + UIColor.black.withAlphaComponent(0.6)]) + let imageView = UIImageView(image: UIImage(systemName: "play.circle.fill")?.withConfiguration(configuration)) + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.isHidden = true + return imageView + }() + + /// Dims the last tile to stand for the media that did not fit the row + private lazy var overflowLabel: UILabel = { + // Deliberately not a PaddedLabel: its insets leave a shrunken tile too little room, and the + // badge truncates to an ellipsis instead of showing the count + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textAlignment = .center + label.textColor = .white + label.font = .preferredFont(for: .title3, weight: .bold) + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = 0.5 + label.backgroundColor = .black.withAlphaComponent(0.5) + label.layer.cornerRadius = chatMessageCellPreviewCornerRadius + label.clipsToBounds = true + label.isHidden = true + return label + }() + + private var sizeConstraints: [NSLayoutConstraint] = [] + private var playIconConstraints: [NSLayoutConstraint] = [] + + override init(frame: CGRect) { + super.init(frame: frame) + self.setupTileView() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + self.setupTileView() + } + + private func setupTileView() { + self.translatesAutoresizingMaskIntoConstraints = false + self.addSubview(self.previewImageView) + self.addSubview(self.playIconImageView) + self.addSubview(self.overflowLabel) + + self.sizeConstraints = [ + self.widthAnchor.constraint(equalToConstant: 0), + self.heightAnchor.constraint(equalToConstant: 0) + ] + + self.playIconConstraints = [ + self.playIconImageView.widthAnchor.constraint(equalToConstant: 0), + self.playIconImageView.heightAnchor.constraint(equalToConstant: 0) + ] + + NSLayoutConstraint.activate(self.sizeConstraints + self.playIconConstraints + [ + + self.previewImageView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + self.previewImageView.trailingAnchor.constraint(equalTo: self.trailingAnchor), + self.previewImageView.topAnchor.constraint(equalTo: self.topAnchor), + self.previewImageView.bottomAnchor.constraint(equalTo: self.bottomAnchor), + + self.playIconImageView.centerXAnchor.constraint(equalTo: self.centerXAnchor), + self.playIconImageView.centerYAnchor.constraint(equalTo: self.centerYAnchor), + + self.overflowLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor), + self.overflowLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor), + self.overflowLabel.topAnchor.constraint(equalTo: self.topAnchor), + self.overflowLabel.bottomAnchor.constraint(equalTo: self.bottomAnchor) + ]) + } + + func setup(with file: NCMessageFileParameter, account: TalkAccount, size: CGFloat, hiddenCount: Int) { + for constraint in self.sizeConstraints { + constraint.constant = size + } + + for constraint in self.playIconConstraints { + constraint.constant = size / 2 + } + + let previewSize = Int(size * UIScreen.main.scale) + + self.previewImageView.setPreview(forFileId: file.parameterId, withWidth: previewSize, withHeight: previewSize, usingAccount: account) + + if let mimetype = file.mimetype, NCUtils.isVideo(fileType: mimetype) { + self.playIconImageView.isHidden = false + } + + if hiddenCount > 0 { + self.overflowLabel.isHidden = false + self.overflowLabel.text = "+\(hiddenCount)" + } + + self.accessibilityLabel = file.name + self.isAccessibilityElement = true + } + + func prepareForReuse() { + self.previewImageView.currentRequest?.cancel() + self.previewImageView.image = nil + self.playIconImageView.isHidden = true + self.overflowLabel.isHidden = true + self.overflowLabel.text = nil + } +} + +/// One file of a group that has no preview, shown as a row with its name. +class GroupedFileRowView: UIControl { + + /// What the card keeps between its edge and its content + static let cardPadding = 8.0 + + private lazy var iconImageView: UIImageView = { + let imageView = UIImageView() + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.contentMode = .scaleAspectFit + return imageView + }() + + private lazy var nameLabel: UILabel = { + let label = UILabel() + // The size the chat writes messages and author names in + label.font = .preferredFont(forTextStyle: .body) + label.lineBreakMode = .byTruncatingMiddle + return label + }() + + private lazy var detailLabel: UILabel = { + let label = UILabel() + // The size the chat writes timestamps in + label.font = .preferredFont(forTextStyle: .footnote) + label.textColor = .secondaryLabel + return label + }() + + /// Shown while the file this row stands for is being downloaded, with its progress once the + /// download can report one + private lazy var downloadIndicator: MDCActivityIndicator = { + let indicator = MDCActivityIndicator(frame: .init(x: 0, y: 0, width: 20, height: 20)) + indicator.translatesAutoresizingMaskIntoConstraints = false + indicator.radius = 6 + indicator.strokeWidth = 1.5 + indicator.cycleColors = [.secondaryLabel] + indicator.isHidden = true + return indicator + }() + + private var fileParameter: NCMessageFileParameter? + + private lazy var labelStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [self.nameLabel, self.detailLabel]) + stackView.axis = .vertical + stackView.alignment = .leading + stackView.translatesAutoresizingMaskIntoConstraints = false + // Otherwise hit testing stops here and the row itself never sees the tap + stackView.isUserInteractionEnabled = false + return stackView + }() + + override init(frame: CGRect) { + super.init(frame: frame) + self.setupRowView() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + self.setupRowView() + } + + private var maximumWidthConstraint: NSLayoutConstraint? + + /// Dims the card while it is held, so that it reads as something that can be tapped + override var isHighlighted: Bool { + didSet { + self.alpha = self.isHighlighted ? 0.6 : 1.0 + } + } + + private func setupRowView() { + self.translatesAutoresizingMaskIntoConstraints = false + + // The same card a link preview is drawn on, so a file of a group reads as its own target + self.backgroundColor = chatBubbleCardFill + self.layer.cornerRadius = chatBubbleCardCornerRadius + self.layer.masksToBounds = true + + self.addSubview(self.iconImageView) + self.addSubview(self.labelStackView) + self.addSubview(self.downloadIndicator) + + let rowHeight = GroupedFilePreviewView.fileRowHeight + let iconSize = UIFont.preferredFont(forTextStyle: .body).lineHeight + UIFont.preferredFont(forTextStyle: .footnote).lineHeight + + // A long file name truncates against this instead of widening the row past the bubble, + // where the part sticking out would draw but take no taps + let maximumWidthConstraint = self.widthAnchor.constraint(lessThanOrEqualToConstant: 0) + self.maximumWidthConstraint = maximumWidthConstraint + + NSLayoutConstraint.activate([ + self.heightAnchor.constraint(equalToConstant: rowHeight), + + self.iconImageView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: GroupedFileRowView.cardPadding), + self.iconImageView.centerYAnchor.constraint(equalTo: self.centerYAnchor), + self.iconImageView.widthAnchor.constraint(equalToConstant: iconSize), + self.iconImageView.heightAnchor.constraint(equalToConstant: iconSize), + + self.labelStackView.leadingAnchor.constraint(equalTo: self.iconImageView.trailingAnchor, constant: 8), + self.labelStackView.trailingAnchor.constraint(equalTo: self.downloadIndicator.leadingAnchor, constant: -8), + self.labelStackView.centerYAnchor.constraint(equalTo: self.centerYAnchor), + + self.downloadIndicator.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -GroupedFileRowView.cardPadding), + self.downloadIndicator.centerYAnchor.constraint(equalTo: self.centerYAnchor), + self.downloadIndicator.widthAnchor.constraint(equalToConstant: 20), + self.downloadIndicator.heightAnchor.constraint(equalToConstant: 20) + ]) + } + + func setup(with file: NCMessageFileParameter, maximumWidth: CGFloat) { + self.maximumWidthConstraint?.constant = maximumWidth + self.maximumWidthConstraint?.isActive = maximumWidth > 0 + + self.fileParameter = file + + if let fileStatus = file.fileStatus, fileStatus.isDownloading { + self.showDownload(withProgress: fileStatus.canReportProgress ? Float(fileStatus.downloadProgress) : 0) + } + + self.iconImageView.image = UIImage(named: NCUtils.previewImage(forMimeType: file.mimetype)) + self.nameLabel.text = file.name + self.detailLabel.text = file.shortDescription + + self.accessibilityLabel = [file.name, file.shortDescription].compactMap { $0 }.joined(separator: ", ") + self.isAccessibilityElement = true + } + + /// Follows the download of the file this row stands for, ignoring the files of the other rows + func updateDownloadStatus(from notification: Notification) { + guard let fileParameter = self.fileParameter, + let status = NCChatFileStatus.getStatus(from: notification, for: fileParameter) + else { return } + + if status.isDownloading { + self.showDownload(withProgress: status.canReportProgress ? Float(status.downloadProgress) : 0) + } else { + self.hideDownload() + } + } + + private func showDownload(withProgress progress: Float) { + self.downloadIndicator.isHidden = false + + if progress > 0 { + self.downloadIndicator.indicatorMode = .determinate + self.downloadIndicator.setProgress(progress, animated: true) + } else { + self.downloadIndicator.indicatorMode = .indeterminate + } + + self.downloadIndicator.startAnimating() + } + + private func hideDownload() { + self.downloadIndicator.stopAnimating() + self.downloadIndicator.isHidden = true + } +} diff --git a/NextcloudTalk/Chat/Chat cells/GroupedFilePreviewView.swift b/NextcloudTalk/Chat/Chat cells/GroupedFilePreviewView.swift new file mode 100644 index 000000000..0f21bc12f --- /dev/null +++ b/NextcloudTalk/Chat/Chat cells/GroupedFilePreviewView.swift @@ -0,0 +1,269 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import UIKit + +protocol GroupedFilePreviewViewDelegate: AnyObject { + /// The file at this position of the group was tapped, counted in the order the files were shared in. + func groupedFilePreviewView(_ view: GroupedFilePreviewView, didSelectFileAt index: Int) +} + +/// The files of one upload, shown as a row of previews. +/// +/// Media with a preview becomes a row of square tiles, everything else a list of rows underneath, +/// which is the layout the web client settled on. Only a few tiles fit a chat bubble, so the last +/// one carries a "+N" badge for the media that does not fit. +class GroupedFilePreviewView: UIView { + + /// Tiles shown before the last one becomes the "+N" badge, when the bubble is wide enough + static let maximumTiles = 4 + + static let tileSize = 80.0 + + /// Tall enough for the two lines a file row shows, so that it follows the text size the reader + /// has chosen instead of clipping at a fixed height + static var fileRowHeight: CGFloat { + let nameHeight = UIFont.preferredFont(forTextStyle: .body).lineHeight + let detailHeight = UIFont.preferredFont(forTextStyle: .footnote).lineHeight + + return ceil(nameHeight + detailHeight) + 2 * GroupedFileRowView.cardPadding + } + + /// Between the tiles, between the cards, and between the two rows, so that a group is spaced + /// the same way wherever you look at it + static let contentSpacing = 4.0 + + /// How many tiles fit next to each other. + /// + /// The tiles keep their size and the row shows fewer of them, rather than the tiles shrinking + /// to fit: the chat view measures a group before building it, and a tile of a fixed size is + /// something both can agree on without knowing the exact width of a bubble. + static func tileCount(forAvailableWidth availableWidth: CGFloat) -> Int { + guard availableWidth > 0 else { return self.maximumTiles } + + let fitting = Int((availableWidth + self.contentSpacing) / (self.tileSize + self.contentSpacing)) + + return max(2, min(self.maximumTiles, fitting)) + } + + weak var delegate: GroupedFilePreviewViewDelegate? + + private var tileViews: [GroupedFilePreviewTileView] = [] + private var fileRowViews: [GroupedFileRowView] = [] + + private lazy var tileRow: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = GroupedFilePreviewView.contentSpacing + stackView.alignment = .top + return stackView + }() + + private lazy var fileRows: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = GroupedFilePreviewView.contentSpacing + stackView.alignment = .fill + return stackView + }() + + /// Takes the width the tiles do not need, so that the stack view stretches this instead of + /// stretching the last tile when a file row is longer than the tile row + private lazy var tileRowSpacer: UIView = { + let view = UIView() + view.setContentHuggingPriority(.defaultLow, for: .horizontal) + view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return view + }() + + private lazy var contentStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [self.tileRow, self.fileRows]) + stackView.axis = .vertical + stackView.spacing = GroupedFilePreviewView.contentSpacing + // The rows are as wide as the widest thing in the bubble, so that all of a row takes taps + stackView.alignment = .fill + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + override init(frame: CGRect) { + super.init(frame: frame) + self.setupContentView() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + self.setupContentView() + } + + private var contentWidthConstraint: NSLayoutConstraint? + + private func setupContentView() { + self.addSubview(self.contentStackView) + self.tileRow.addArrangedSubview(self.tileRowSpacer) + + // A cap, not a width: the group is otherwise as wide as the longest thing in it, so a + // bubble of file rows ends at the longest name instead of running to the edge + let contentWidthConstraint = self.widthAnchor.constraint(lessThanOrEqualToConstant: 0) + self.contentWidthConstraint = contentWidthConstraint + + // The rows keep the height they need. When the cell turns out taller than the group, as a + // hand written height calculation now and then will, the difference is left below them + // rather than stretching a card to fill it. + let bottomConstraint = self.contentStackView.bottomAnchor.constraint(equalTo: self.bottomAnchor) + bottomConstraint.priority = .defaultHigh + + NSLayoutConstraint.activate([ + self.contentStackView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + self.contentStackView.trailingAnchor.constraint(equalTo: self.trailingAnchor), + self.contentStackView.topAnchor.constraint(equalTo: self.topAnchor), + self.contentStackView.bottomAnchor.constraint(lessThanOrEqualTo: self.bottomAnchor), + bottomConstraint + ]) + } + + // MARK: - Layout of a group + + /// How the files of a group are split between the two rows, which is all the height of the view + /// depends on. Kept separate so that the chat view can measure a group without building it. + struct Layout { + let tiles: [NCMessageFileParameter] + let files: [NCMessageFileParameter] + + /// The media that does not fit the row, shown as a badge on the last tile + let hiddenTileCount: Int + + init(files: [NCMessageFileParameter], availableWidth: CGFloat) { + let media = files.filter { $0.isPreviewableMedia } + let others = files.filter { !$0.isPreviewableMedia } + let fittingTiles = GroupedFilePreviewView.tileCount(forAvailableWidth: availableWidth) + + if media.count > fittingTiles { + // The last tile becomes the badge, so it stands for itself and everything after it + self.tiles = Array(media.prefix(fittingTiles)) + self.hiddenTileCount = media.count - (fittingTiles - 1) + } else { + self.tiles = media + self.hiddenTileCount = 0 + } + + self.files = others + } + + var height: CGFloat { + var height = 0.0 + + if !self.tiles.isEmpty { + // The tiles are square + height += GroupedFilePreviewView.tileSize + } + + if !self.files.isEmpty { + height += CGFloat(self.files.count) * GroupedFilePreviewView.fileRowHeight + height += CGFloat(self.files.count - 1) * GroupedFilePreviewView.contentSpacing + + if !self.tiles.isEmpty { + height += GroupedFilePreviewView.contentSpacing + } + } + + return height + } + + /// The widest the group may be drawn. Within it the group is as wide as its content. + /// + /// A full row of tiles caps it, so that a long file name truncates rather than stretching + /// the bubble and leaving a gap beside the tiles that nothing could fill. + /// + /// A row with room for another tile does not: the gap next to it is one the tiles could + /// have used, and holding the file names down to a couple of tiles' width costs more than + /// the gap does. Groups without any tiles have nothing to go by and may use the full width. + func maximumContentWidth(forAvailableWidth availableWidth: CGFloat) -> CGFloat { + let fittingTiles = GroupedFilePreviewView.tileCount(forAvailableWidth: availableWidth) + + guard !self.tiles.isEmpty, self.tiles.count >= fittingTiles else { return availableWidth } + + let tileCount = CGFloat(self.tiles.count) + let tileRowWidth = tileCount * GroupedFilePreviewView.tileSize + (tileCount - 1) * GroupedFilePreviewView.contentSpacing + + return min(availableWidth, tileRowWidth) + } + } + + // MARK: - Content + + func setup(with group: FileMessageGroup, account: TalkAccount, availableWidth: CGFloat) { + let files = group.messagesInUploadOrder.compactMap { $0.file() } + let layout = Layout(files: files, availableWidth: availableWidth) + + let maximumContentWidth = layout.maximumContentWidth(forAvailableWidth: availableWidth) + + self.prepareForReuse() + + // Nothing may grow past this: a subview outside the bubble is not only clipped, it also + // stops receiving taps + self.contentWidthConstraint?.constant = maximumContentWidth + self.contentWidthConstraint?.isActive = maximumContentWidth > 0 + + for (index, file) in layout.tiles.enumerated() { + let isBadge = layout.hiddenTileCount > 0 && index == layout.tiles.count - 1 + let tileView = GroupedFilePreviewTileView() + + tileView.setup(with: file, account: account, size: GroupedFilePreviewView.tileSize, hiddenCount: isBadge ? layout.hiddenTileCount : 0) + tileView.addAction { [weak self] in + guard let self else { return } + + self.delegate?.groupedFilePreviewView(self, didSelectFileAt: self.index(of: file, in: files)) + } + + self.tileViews.append(tileView) + self.tileRow.insertArrangedSubview(tileView, at: index) + } + + for file in layout.files { + let rowView = GroupedFileRowView() + + rowView.setup(with: file, maximumWidth: maximumContentWidth) + rowView.addAction { [weak self] in + guard let self else { return } + + self.delegate?.groupedFilePreviewView(self, didSelectFileAt: self.index(of: file, in: files)) + } + + self.fileRowViews.append(rowView) + self.fileRows.addArrangedSubview(rowView) + } + + self.tileRow.isHidden = layout.tiles.isEmpty + self.fileRows.isHidden = layout.files.isEmpty + } + + /// Passes a download notification on to the row it belongs to, if any of them. + func updateDownloadStatus(from notification: Notification) { + for rowView in self.fileRowViews { + rowView.updateDownloadStatus(from: notification) + } + } + + private func index(of file: NCMessageFileParameter, in files: [NCMessageFileParameter]) -> Int { + return files.firstIndex { $0.parameterId == file.parameterId } ?? 0 + } + + func prepareForReuse() { + for tileView in self.tileViews { + tileView.prepareForReuse() + self.tileRow.removeArrangedSubview(tileView) + tileView.removeFromSuperview() + } + + for rowView in self.fileRowViews { + self.fileRows.removeArrangedSubview(rowView) + rowView.removeFromSuperview() + } + + self.tileViews = [] + self.fileRowViews = [] + } +} diff --git a/NextcloudTalk/Chat/Chat upload/ChatFileUpload.swift b/NextcloudTalk/Chat/Chat upload/ChatFileUpload.swift index 4d2c0ee8a..efcbed5d2 100644 --- a/NextcloudTalk/Chat/Chat upload/ChatFileUpload.swift +++ b/NextcloudTalk/Chat/Chat upload/ChatFileUpload.swift @@ -21,7 +21,10 @@ struct ChatFileUpload { var metadata = ChatFileUploadMetadata() - /// Reference id of the temporary message this upload belongs to, if there is one. + /// Reference id of the message this upload will become. + /// + /// Files shared in one go carry the same upload hash here, which is how the clients recognize + /// them as one upload and show them as a single message. See `referenceId(uploadId:index:)`. var referenceId: String? /// Whether the other participants may modify the file, instead of only viewing it. @@ -30,3 +33,16 @@ struct ChatFileUpload { /// separate subfolder, so this is a choice per upload and does not affect earlier ones. var allowUpdate = false } + +extension ChatFileUpload { + + /// Builds the reference id for one file of an upload, so that the clients can recognize the + /// files shared together and show them as a single message. See `FileUploadReference`. + /// + /// - Parameter uploadId: Identifies one upload. The same value has to be passed for every file + /// shared together, and a different one for the next upload. + /// - Parameter index: Zero-based position of the file within the upload. + static func referenceId(uploadId: String, index: Int) -> String? { + return FileUploadReference(uploadId: uploadId, index: index)?.referenceId + } +} diff --git a/NextcloudTalk/Chat/Chat upload/ChatFileUploader.swift b/NextcloudTalk/Chat/Chat upload/ChatFileUploader.swift index e360e3d9b..cebe646cc 100644 --- a/NextcloudTalk/Chat/Chat upload/ChatFileUploader.swift +++ b/NextcloudTalk/Chat/Chat upload/ChatFileUploader.swift @@ -25,6 +25,10 @@ enum ChatFileUploader { /// All uploads need to be for the same conversation and account: with conversation subfolders /// enabled, the draft folder is requested once for all of them. /// + /// The files are uploaded in parallel, but posted into the conversation one after the other, in + /// the order they are given in. Files shared together are only recognizable as one upload while + /// their messages sit next to each other, and the server orders those as they arrive. + /// /// - Parameter progress: Called with the index of an upload and the fraction of it that has been /// uploaded so far. /// - Throws: When the draft folder could not be prepared, in which case nothing was uploaded. @@ -45,7 +49,19 @@ enum ChatFileUploader { allowUpdate: firstUpload.allowUpdate) } - return await withTaskGroup(of: (index: Int, result: Result).self) { group in + let destinations = await self.put(uploads, inDraftFolder: draftFolder, progress: progress) + + return await self.announce(uploads, at: destinations) + } + + /// Uploads the files in parallel, without posting anything into the conversation yet. + /// + /// - Returns: Where each file was uploaded to, or why it could not be uploaded, in the order + /// the uploads were given in. + private static func put(_ uploads: [ChatFileUpload], + inDraftFolder draftFolder: String?, + progress: ((_ index: Int, _ fractionCompleted: Double) -> Void)?) async -> [Result] { + return await withTaskGroup(of: (index: Int, result: Result).self) { group in for (index, upload) in uploads.enumerated() { group.addTask { do { @@ -58,23 +74,48 @@ enum ChatFileUploader { } try await self.put(upload, to: destination, progress: { progress?(index, $0) }, mayCreateAttachmentFolder: true) - try await self.announce(upload, at: destination) - return (index, .success(())) + return (index, .success(destination)) } catch { return (index, .failure(error)) } } } - var results = [Result](repeating: .success(()), count: uploads.count) + var destinations: [(index: Int, result: Result)] = [] for await taskResult in group { - results[taskResult.index] = taskResult.result + destinations.append(taskResult) } - return results + // The uploads finish in any order, the caller expects the order it gave them in + return destinations.sorted { $0.index < $1.index }.map(\.result) + } + } + + /// Posts the uploaded files into the conversation, one after the other in the order they were + /// given in, so that files shared together end up next to each other. + /// + /// - Returns: One result per upload, carrying the upload error for files that never made it to + /// the server. + private static func announce(_ uploads: [ChatFileUpload], + at destinations: [Result]) async -> [Result] { + var results = [Result](repeating: .success(()), count: uploads.count) + + for (index, upload) in uploads.enumerated() { + switch destinations[index] { + case .success(let destination): + do { + try await self.announce(upload, at: destination) + } catch { + results[index] = .failure(error) + } + case .failure(let error): + results[index] = .failure(error) + } } + + return results } // MARK: - Destination diff --git a/NextcloudTalk/Chat/Chat upload/FileUploadReference.swift b/NextcloudTalk/Chat/Chat upload/FileUploadReference.swift new file mode 100644 index 000000000..9460f12ad --- /dev/null +++ b/NextcloudTalk/Chat/Chat upload/FileUploadReference.swift @@ -0,0 +1,67 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation + +/// The upload a shared file belongs to, encoded in the reference id of its message. +/// +/// The server stores one message per shared file, so files shared in one go are only recognizable +/// as belonging together by their reference id. The clients agreed on the format `-`: +/// 60 characters identifying the upload, a dash, and the position of the file within that upload, +/// padded to three digits. Reference ids that do not follow it belong to an upload of their own, +/// which is how messages from older clients keep being shown one by one. +struct FileUploadReference: Equatable { + + /// Identifies the upload. Every file shared together carries the same one. + let uploadHash: String + + /// Position of the file within its upload, starting at 1. + let position: Int + + private static let uploadHashLength = 60 + + /// The format the clients agreed on in nextcloud/spreed#19040 + private static let format = /([a-f0-9]{60})-([0-9]{3})/ + + /// The largest upload that can still be numbered within the 64 characters the server keeps of + /// a reference id. Beyond that it truncates, which would corrupt the position. + static let maximumFileCount = 999 + + /// The reference id to post this file with. + var referenceId: String { + "\(self.uploadHash)-\(String(format: "%03d", self.position))" + } + + /// Reads the upload a message belongs to. + /// + /// - Parameter referenceId: Reference id of the message. Returns nil when it does not follow + /// the format above, in which case the file was not shared as part of + /// an upload this client can recognize. + init?(referenceId: String?) { + guard let referenceId, + let match = referenceId.wholeMatch(of: Self.format), + let position = Int(match.2) + else { + return nil + } + + self.uploadHash = String(match.1) + self.position = position + } + + /// Describes one file of an upload. + /// + /// - Parameter uploadId: Identifies one upload. The same value has to be used for every file + /// shared together, and a different one for the next upload. + /// - Parameter index: Zero-based position of the file within the upload. Returns nil beyond + /// `maximumFileCount`, so those files are posted ungrouped rather than with + /// a reference id the server would reject. + init?(uploadId: String, index: Int) { + guard index >= 0, index < Self.maximumFileCount else { return nil } + + self.uploadHash = String(NCUtils.sha256(fromString: uploadId).prefix(Self.uploadHashLength)) + self.position = index + 1 + } +} diff --git a/NextcloudTalk/Chat/Chat views/References/ReferenceView.swift b/NextcloudTalk/Chat/Chat views/References/ReferenceView.swift index 78aa6f910..6a65cec31 100644 --- a/NextcloudTalk/Chat/Chat views/References/ReferenceView.swift +++ b/NextcloudTalk/Chat/Chat views/References/ReferenceView.swift @@ -11,15 +11,6 @@ class ReferenceView: UIView { var activityIndicator: MDCActivityIndicator = MDCActivityIndicator(frame: CGRect(x: 0, y: 0, width: 50, height: 50)) - /// A filled card instead of a hairline border, which used the very same translucent colour and so - /// would have doubled up. White in both appearances, so the card reads as a panel *lighter* than the - /// bubble – the semantic fills darken instead. Light mode needs the higher alpha, starting lighter. - private static let backgroundFill = UIColor { traitCollection in - let alpha = traitCollection.userInterfaceStyle == .dark ? 0.10 : 0.65 - - return UIColor.white.withAlphaComponent(alpha) - } - private var aspectRatioConstraint: NSLayoutConstraint? /// The GIF whose load the indicator is waiting on. A load can outlive the card that started it – by @@ -76,10 +67,10 @@ class ReferenceView: UIView { activityIndicatorView.heightAnchor.constraint(equalToConstant: activityIndicator.frame.height) ]) - layer.cornerRadius = 8.0 + layer.cornerRadius = chatBubbleCardCornerRadius layer.masksToBounds = true - backgroundColor = ReferenceView.backgroundFill + backgroundColor = chatBubbleCardFill self.addSubview(contentView) } diff --git a/NextcloudTalk/Chat/FileMessageGroup.swift b/NextcloudTalk/Chat/FileMessageGroup.swift new file mode 100644 index 000000000..b21a10b4d --- /dev/null +++ b/NextcloudTalk/Chat/FileMessageGroup.swift @@ -0,0 +1,89 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation + +/// The files of one upload, shown as a single message instead of one message per file. +/// +/// The server stores one message per shared file and knows nothing about this. Mirrors +/// `combineFileMessages.ts` of the web client, so that a conversation reads the same everywhere. +struct FileMessageGroup { + + /// The messages of the group, in the order they appear in the conversation. + /// + /// `groups(in:)` only ever builds these from two messages or more. A single file drawn the same + /// way, which is what a file without a preview gets, is a group of one. + let messages: [NCChatMessage] + + /// The message the group is shown as. + /// + /// The last one, so that the timestamp, the read state and the message actions of the group are + /// those of its newest message. A caption ends the group it belongs to, so the caption of an + /// upload is always the text of this message. + var anchor: NCChatMessage { + return self.messages[self.messages.count - 1] + } + + /// The messages of the group in the order their files were shared in. + /// + /// Not necessarily the order the messages arrived in: a client that posts the files of an + /// upload in parallel has them arrive in any order, and one that failed to upload a file in + /// the middle leaves a gap. + var messagesInUploadOrder: [NCChatMessage] { + return self.messages.sorted { first, second in + (first.fileUploadReference?.position ?? 0) < (second.fileUploadReference?.position ?? 0) + } + } + + /// Splits the messages of a conversation, in the order they are shown in, into the groups of + /// files that were shared as one upload. + /// + /// A file shared on its own stays an ordinary message, so only runs of more than one message + /// are returned. Anything that is not a plain file share of the same upload ends the run before + /// it, and so does a reply to another message. A file shared with a caption ends the run it + /// belongs to, because the caption is added to the file shared last. + static func groups(in messages: [NCChatMessage]) -> [FileMessageGroup] { + var groups: [FileMessageGroup] = [] + var run: [NCChatMessage] = [] + + func endRun() { + if run.count > 1 { + groups.append(FileMessageGroup(messages: run)) + } + + run = [] + } + + for message in messages { + guard message.isGroupableFileMessage else { + endRun() + continue + } + + if let previous = run.last, !self.belongToTheSameUpload(message, previous) { + endRun() + } + + run.append(message) + + if !message.sharesFileWithoutCaption { + endRun() + } + } + + endRun() + + return groups + } + + /// Whether two file shares are part of the same upload, and reply to the same message. + /// + /// Their position within the upload is deliberately not compared: a file that failed to upload + /// leaves a gap, and the files around it still belong together. + private static func belongToTheSameUpload(_ message: NCChatMessage, _ other: NCChatMessage) -> Bool { + return message.fileUploadReference?.uploadHash == other.fileUploadReference?.uploadHash + && message.parentId == other.parentId + } +} diff --git a/NextcloudTalk/Chat/NCChatMessage+FileGrouping.swift b/NextcloudTalk/Chat/NCChatMessage+FileGrouping.swift new file mode 100644 index 000000000..bee054c5d --- /dev/null +++ b/NextcloudTalk/Chat/NCChatMessage+FileGrouping.swift @@ -0,0 +1,86 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation + +extension NCChatMessage { + + /// The upload the file of this message was shared as part of, when its reference id follows the + /// format the clients agreed on. Nil for everything shared before that, which is then shown as + /// a message of its own. + var fileUploadReference: FileUploadReference? { + return FileUploadReference(referenceId: self.referenceId) + } + + /// Whether this message shares a file that may be shown together with the other files of its + /// upload, instead of as a message of its own. + /// + /// Mirrors what the other clients group, so that a conversation reads the same everywhere: + /// anything rendered by a widget of its own stays on its own, and so does a message that + /// shares more than a single file. + /// + /// Note that a file shared with a caption is groupable as well. The caption ends the group it + /// belongs to, but that is a property of the group, not of the message. + var isGroupableFileMessage: Bool { + return self.isPlainFileShare && self.fileUploadReference != nil + } + + /// Whether a file shown on its own is drawn on a card, the way the files of a group are. + /// + /// Only files the server has no preview of. Media keeps the large preview it is worth showing, + /// while everything else said no more than its name and a generic icon the size of a photo. + var isFileCardMessage: Bool { + guard let file = self.file(), !file.isPreviewableMedia else { return false } + + return self.isPlainFileShare + } + + /// Whether this message shares a single file and nothing else, whatever upload it came from. + private var isPlainFileShare: Bool { + // A message that failed to send, or is being deleted, keeps its own bubble so that its + // state stays visible. Voice messages are excluded by the message type below. + guard !self.isSystemMessage, !self.isDeletedMessage, !self.sendingFailed, !self.isDeleting else { + return false + } + + // An edited message is shown separately, the same way message grouping treats it + guard self.messageType == kMessageTypeComment, self.lastEditTimestamp == 0 else { + return false + } + + // `file()` returns nil when a message shares more than one file, so this is also the check + // for the message sharing exactly one + guard let file = self.file(), let mimetype = file.mimetype else { + return false + } + + guard !self.isObjectShare, self.poll == nil, self.geoLocation() == nil, self.deckCard() == nil else { + return false + } + + // A contact card is drawn with the photo of the contact, which a group has nowhere to show. + // Audio files are excluded on web because it renders a player for them, this client does + // not: it shows them with the ordinary file cell, so they group like any other file. + guard mimetype != "text/vcard" else { + return false + } + + return true + } + + /// Whether the message carries nothing but the placeholder of its file, which is what a file + /// shared without a caption looks like. + var sharesFileWithoutCaption: Bool { + let text = self.message.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !text.isEmpty else { return true } + guard text.hasPrefix("{file"), text.hasSuffix("}") else { return false } + + // Either '{file}' or '{file-1}', depending on how many files the sender put in the message + let position = text.dropFirst("{file".count).dropLast() + + return position.isEmpty || (position.hasPrefix("-") && position.count > 1 && position.dropFirst().allSatisfy(\.isNumber)) + } +} diff --git a/NextcloudTalk/Settings/NCUtils.swift b/NextcloudTalk/Settings/NCUtils.swift index 00a0c76b7..4b5d2a933 100644 --- a/NextcloudTalk/Settings/NCUtils.swift +++ b/NextcloudTalk/Settings/NCUtils.swift @@ -317,6 +317,16 @@ import AVFoundation return hexBytes.joined() } + public static func sha256(fromString string: String) -> String { + let data = string.data(using: .utf8)! + var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + data.withUnsafeBytes { + _ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &digest) + } + let hexBytes = digest.map { String(format: "%02hhx", $0) } + return hexBytes.joined() + } + // MARK: - Image utils public static func blurImage(fromImage image: UIImage) -> UIImage? { diff --git a/NextcloudTalkTests/Unit/Chat/UnitChatFileUploadReferenceIdTest.swift b/NextcloudTalkTests/Unit/Chat/UnitChatFileUploadReferenceIdTest.swift new file mode 100644 index 000000000..d743def3a --- /dev/null +++ b/NextcloudTalkTests/Unit/Chat/UnitChatFileUploadReferenceIdTest.swift @@ -0,0 +1,51 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import XCTest +@testable import NextcloudTalk + +/// The reference id format that lets the clients recognize files shared in one go. +final class UnitChatFileUploadReferenceIdTest: XCTestCase { + + func testReferenceIdHasTheFormatSharedWithTheOtherClients() throws { + let referenceId = try XCTUnwrap(ChatFileUpload.referenceId(uploadId: "upload", index: 0)) + + // 60 characters of hash, a dash and three digits, which is what the server allows + XCTAssertEqual(referenceId.count, 64) + + let parts = referenceId.split(separator: "-") + XCTAssertEqual(parts.count, 2) + XCTAssertEqual(parts[0].count, 60) + XCTAssertEqual(parts[1], "001") + XCTAssertTrue(parts[0].allSatisfy(\.isHexDigit)) + } + + func testFilesOfTheSameUploadShareTheirHash() throws { + let first = try XCTUnwrap(ChatFileUpload.referenceId(uploadId: "upload", index: 0)) + let second = try XCTUnwrap(ChatFileUpload.referenceId(uploadId: "upload", index: 1)) + + XCTAssertEqual(first.prefix(60), second.prefix(60)) + XCTAssertNotEqual(first, second) + } + + func testFilesOfDifferentUploadsDoNotShareTheirHash() throws { + let first = try XCTUnwrap(ChatFileUpload.referenceId(uploadId: "upload", index: 0)) + let second = try XCTUnwrap(ChatFileUpload.referenceId(uploadId: "another upload", index: 0)) + + XCTAssertNotEqual(first.prefix(60), second.prefix(60)) + } + + func testIndexIsPaddedToThreeDigits() throws { + XCTAssertEqual(try XCTUnwrap(ChatFileUpload.referenceId(uploadId: "upload", index: 8)).suffix(3), "009") + XCTAssertEqual(try XCTUnwrap(ChatFileUpload.referenceId(uploadId: "upload", index: 98)).suffix(3), "099") + XCTAssertEqual(try XCTUnwrap(ChatFileUpload.referenceId(uploadId: "upload", index: 998)).suffix(3), "999") + } + + func testUploadsTooLargeToNumberAreNotGrouped() { + // A fourth digit would exceed the 64 characters the server allows + XCTAssertNil(ChatFileUpload.referenceId(uploadId: "upload", index: 999)) + XCTAssertNil(ChatFileUpload.referenceId(uploadId: "upload", index: -1)) + } +} diff --git a/NextcloudTalkTests/Unit/Chat/UnitFileMessageGroupTest.swift b/NextcloudTalkTests/Unit/Chat/UnitFileMessageGroupTest.swift new file mode 100644 index 000000000..881e9ed29 --- /dev/null +++ b/NextcloudTalkTests/Unit/Chat/UnitFileMessageGroupTest.swift @@ -0,0 +1,182 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import XCTest +@testable import NextcloudTalk + +/// Which consecutive file messages are shown as one upload. +final class UnitFileMessageGroupTest: TestBaseRealm { + + private let uploadA = String(repeating: "a", count: 60) + private let uploadB = String(repeating: "b", count: 60) + + private var nextMessageId = 0 + + /// One file share of an upload, or any other message when the overrides say so + private func message(upload: String? = nil, + position: Int = 1, + text: String = "{file}", + overrides: [String: Any] = [:]) throws -> NCChatMessage { + self.nextMessageId += 1 + + var dict: [String: Any] = [ + "id": self.nextMessageId, + "token": "token", + "message": text, + "messageType": "comment", + "systemMessage": "", + "actorId": "alice", + "actorType": "users", + "messageParameters": ["file": ["type": "file", "id": "9", "name": "IMG.jpg", "path": "IMG.jpg", "mimetype": "image/jpeg"]] + ] + + if let upload { + dict["referenceId"] = "\(upload)-\(String(format: "%03d", position))" + } + + dict.merge(overrides) { _, override in override } + + return try XCTUnwrap(NCChatMessage(dictionary: dict, andAccountId: TestBaseRealm.fakeAccountId)) + } + + private func textMessage() throws -> NCChatMessage { + return try self.message(text: "Hi", overrides: ["messageParameters": [:]]) + } + + // MARK: - What is grouped + + func testFilesOfOneUploadAreGrouped() throws { + let messages = [try self.message(upload: self.uploadA, position: 1), + try self.message(upload: self.uploadA, position: 2), + try self.message(upload: self.uploadA, position: 3)] + + let groups = FileMessageGroup.groups(in: messages) + + XCTAssertEqual(groups.count, 1) + XCTAssertEqual(groups.first?.messages.count, 3) + } + + func testAFileSharedOnItsOwnStaysAnOrdinaryMessage() throws { + let groups = FileMessageGroup.groups(in: [try self.message(upload: self.uploadA)]) + + XCTAssertTrue(groups.isEmpty) + } + + func testFilesSharedBeforeTheAgreedReferenceIdAreNotGrouped() throws { + let messages = [try self.message(), try self.message()] + + XCTAssertTrue(FileMessageGroup.groups(in: messages).isEmpty) + } + + /// The group is anchored on the last message, so its timestamp and read state are the newest + func testTheGroupIsShownAsItsLastMessage() throws { + let messages = [try self.message(upload: self.uploadA, position: 1), + try self.message(upload: self.uploadA, position: 2)] + + let group = try XCTUnwrap(FileMessageGroup.groups(in: messages).first) + + XCTAssertEqual(group.anchor.messageId, messages[1].messageId) + } + + /// A client that uploads in parallel has the messages arrive in any order + func testTilesFollowTheOrderTheFilesWereSharedIn() throws { + let messages = [try self.message(upload: self.uploadA, position: 3), + try self.message(upload: self.uploadA, position: 1), + try self.message(upload: self.uploadA, position: 2)] + + let group = try XCTUnwrap(FileMessageGroup.groups(in: messages).first) + + XCTAssertEqual(group.messagesInUploadOrder.map { $0.fileUploadReference?.position }, [1, 2, 3]) + XCTAssertEqual(group.anchor.messageId, messages[2].messageId, "The anchor still follows the conversation") + } + + /// A file that failed to upload leaves a gap, the files around it still belong together + func testAMissingFileDoesNotSplitAnUpload() throws { + let messages = [try self.message(upload: self.uploadA, position: 1), + try self.message(upload: self.uploadA, position: 3)] + + XCTAssertEqual(FileMessageGroup.groups(in: messages).count, 1) + } + + // MARK: - What ends a group + + func testTwoUploadsAreNotMergedIntoOne() throws { + let messages = [try self.message(upload: self.uploadA, position: 1), + try self.message(upload: self.uploadA, position: 2), + try self.message(upload: self.uploadB, position: 1), + try self.message(upload: self.uploadB, position: 2)] + + let groups = FileMessageGroup.groups(in: messages) + + XCTAssertEqual(groups.count, 2) + XCTAssertEqual(groups.map { $0.messages.count }, [2, 2]) + } + + func testAMessageInBetweenEndsTheGroup() throws { + let messages = [try self.message(upload: self.uploadA, position: 1), + try self.message(upload: self.uploadA, position: 2), + try self.textMessage(), + try self.message(upload: self.uploadA, position: 3), + try self.message(upload: self.uploadA, position: 4)] + + XCTAssertEqual(FileMessageGroup.groups(in: messages).map { $0.messages.count }, [2, 2]) + } + + func testAVoiceMessageInBetweenEndsTheGroup() throws { + let messages = [try self.message(upload: self.uploadA, position: 1), + try self.message(upload: self.uploadA, position: 2), + try self.message(upload: self.uploadA, position: 3, overrides: ["messageType": "voice-message"]), + try self.message(upload: self.uploadA, position: 4)] + + XCTAssertEqual(FileMessageGroup.groups(in: messages).map { $0.messages.count }, [2]) + } + + /// The caption is added to the file shared last, so it is the last thing in its group + func testACaptionEndsTheGroupItBelongsTo() throws { + let messages = [try self.message(upload: self.uploadA, position: 1), + try self.message(upload: self.uploadA, position: 2, text: "Look at these {file}"), + try self.message(upload: self.uploadA, position: 3)] + + let groups = FileMessageGroup.groups(in: messages) + + XCTAssertEqual(groups.count, 1) + XCTAssertEqual(groups.first?.messages.count, 2) + XCTAssertEqual(groups.first?.anchor.message, "Look at these {file}", "The caption is the text of the group") + } + + func testRepliesToDifferentMessagesAreNotGrouped() throws { + let first = try self.message(upload: self.uploadA, position: 1) + let second = try self.message(upload: self.uploadA, position: 2) + first.parentId = "one" + second.parentId = "another" + + XCTAssertTrue(FileMessageGroup.groups(in: [first, second]).isEmpty) + } + + func testRepliesToTheSameMessageAreGrouped() throws { + let first = try self.message(upload: self.uploadA, position: 1) + let second = try self.message(upload: self.uploadA, position: 2) + first.parentId = "one" + second.parentId = "one" + + XCTAssertEqual(FileMessageGroup.groups(in: [first, second]).count, 1) + } + + // MARK: - Recognizing a caption + + func testFilePlaceholdersAreNotACaption() throws { + XCTAssertTrue(try self.message(text: "{file}").sharesFileWithoutCaption) + XCTAssertTrue(try self.message(text: " {file} ").sharesFileWithoutCaption) + XCTAssertTrue(try self.message(text: "{file-12}").sharesFileWithoutCaption) + XCTAssertTrue(try self.message(text: "").sharesFileWithoutCaption) + } + + func testTextAroundAPlaceholderIsACaption() throws { + XCTAssertFalse(try self.message(text: "Look {file}").sharesFileWithoutCaption) + XCTAssertFalse(try self.message(text: "{file} indeed").sharesFileWithoutCaption) + XCTAssertFalse(try self.message(text: "{filet}").sharesFileWithoutCaption) + XCTAssertFalse(try self.message(text: "{file-}").sharesFileWithoutCaption) + } +} diff --git a/NextcloudTalkTests/Unit/Chat/UnitFileUploadReferenceTest.swift b/NextcloudTalkTests/Unit/Chat/UnitFileUploadReferenceTest.swift new file mode 100644 index 000000000..d67e12dab --- /dev/null +++ b/NextcloudTalkTests/Unit/Chat/UnitFileUploadReferenceTest.swift @@ -0,0 +1,85 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import XCTest +@testable import NextcloudTalk + +/// Reading back the upload a shared file belongs to from its reference id. +final class UnitFileUploadReferenceTest: XCTestCase { + + private let uploadHash = String(repeating: "a", count: 60) + + func testReadsBackWhatItWrote() throws { + let written = try XCTUnwrap(FileUploadReference(uploadId: "upload", index: 4)) + let read = try XCTUnwrap(FileUploadReference(referenceId: written.referenceId)) + + XCTAssertEqual(read, written) + XCTAssertEqual(read.position, 5) + } + + func testFilesOfTheSameUploadShareTheirHash() throws { + let first = try XCTUnwrap(FileUploadReference(uploadId: "upload", index: 0)) + let second = try XCTUnwrap(FileUploadReference(uploadId: "upload", index: 1)) + + XCTAssertEqual(first.uploadHash, second.uploadHash) + XCTAssertNotEqual(first.position, second.position) + } + + func testFilesOfDifferentUploadsDoNotShareTheirHash() throws { + let first = try XCTUnwrap(FileUploadReference(uploadId: "upload", index: 0)) + let second = try XCTUnwrap(FileUploadReference(uploadId: "another upload", index: 0)) + + XCTAssertNotEqual(first.uploadHash, second.uploadHash) + } + + func testUploadsTooLargeToNumberAreNotGrouped() { + XCTAssertNil(FileUploadReference(uploadId: "upload", index: FileUploadReference.maximumFileCount)) + XCTAssertNil(FileUploadReference(uploadId: "upload", index: -1)) + } + + // MARK: - Reference ids that are not part of an upload + + func testReferenceIdsOfOlderClientsBelongToNoUpload() { + // What this client wrote before the format was agreed on: a plain SHA-1 and a plain SHA-256 + XCTAssertNil(FileUploadReference(referenceId: String(repeating: "a", count: 40))) + XCTAssertNil(FileUploadReference(referenceId: String(repeating: "a", count: 64))) + } + + func testMalformedReferenceIdsBelongToNoUpload() { + XCTAssertNil(FileUploadReference(referenceId: nil)) + XCTAssertNil(FileUploadReference(referenceId: "")) + XCTAssertNil(FileUploadReference(referenceId: "temp-1758012345.678")) + // Right shape, wrong hash length + XCTAssertNil(FileUploadReference(referenceId: "\(String(repeating: "a", count: 59))-001")) + XCTAssertNil(FileUploadReference(referenceId: "\(String(repeating: "a", count: 61))-001")) + // Right hash length, no usable position + XCTAssertNil(FileUploadReference(referenceId: "\(self.uploadHash)-abc")) + XCTAssertNil(FileUploadReference(referenceId: self.uploadHash)) + XCTAssertNil(FileUploadReference(referenceId: "\(self.uploadHash)-001-002")) + } + + /// The other clients validate against /[a-f0-9]{60}-[0-9]{3}/, so anything this client accepts + /// on top of that would group messages here that stay separate on web and Android. + func testFormatIsNotAcceptedMoreLooselyThanByTheOtherClients() { + // Uppercase hash + XCTAssertNil(FileUploadReference(referenceId: "\(String(repeating: "A", count: 60))-001")) + // Hash that is not hexadecimal + XCTAssertNil(FileUploadReference(referenceId: "\(String(repeating: "z", count: 60))-001")) + // Position that is not padded to three digits + XCTAssertNil(FileUploadReference(referenceId: "\(self.uploadHash)-1")) + XCTAssertNil(FileUploadReference(referenceId: "\(self.uploadHash)-0001")) + // Position that is signed rather than a plain number + XCTAssertNil(FileUploadReference(referenceId: "\(self.uploadHash)-+01")) + } + + func testTwoFilesOfOneUploadAreRecognizedAsBelongingTogether() throws { + let first = try XCTUnwrap(FileUploadReference(referenceId: "\(self.uploadHash)-001")) + let second = try XCTUnwrap(FileUploadReference(referenceId: "\(self.uploadHash)-002")) + + XCTAssertEqual(first.uploadHash, second.uploadHash) + XCTAssertEqual(first.position, 1) + XCTAssertEqual(second.position, 2) + } +} diff --git a/NextcloudTalkTests/Unit/Chat/UnitNCChatMessageFileGroupingTest.swift b/NextcloudTalkTests/Unit/Chat/UnitNCChatMessageFileGroupingTest.swift new file mode 100644 index 000000000..da1fddadc --- /dev/null +++ b/NextcloudTalkTests/Unit/Chat/UnitNCChatMessageFileGroupingTest.swift @@ -0,0 +1,164 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import XCTest +@testable import NextcloudTalk + +/// Which messages may be shown together with the other files of their upload. +final class UnitNCChatMessageFileGroupingTest: TestBaseRealm { + + private let uploadHash = String(repeating: "a", count: 60) + + private func fileParameter(mimetype: String = "image/jpeg") -> [String: Any] { + return ["type": "file", "id": "9", "name": "IMG_0001.jpg", "path": "IMG_0001.jpg", "mimetype": mimetype] + } + + /// A file share as the server sends it, which the individual tests then vary + private func message(_ overrides: [String: Any] = [:], parameters: [String: Any]? = nil) throws -> NCChatMessage { + var dict: [String: Any] = [ + "id": 1, + "token": "token", + "message": "{file}", + "messageType": "comment", + "systemMessage": "", + "actorId": "alice", + "actorType": "users", + "referenceId": "\(self.uploadHash)-001", + "messageParameters": parameters ?? ["file": self.fileParameter()] + ] + + dict.merge(overrides) { _, override in override } + + return try XCTUnwrap(NCChatMessage(dictionary: dict, andAccountId: TestBaseRealm.fakeAccountId)) + } + + func testAPlainFileShareOfAnUploadIsGroupable() throws { + XCTAssertTrue(try self.message().isGroupableFileMessage) + } + + /// The caption ends the group it belongs to, but the message is still part of it + func testAFileSharedWithACaptionIsGroupable() throws { + XCTAssertTrue(try self.message(["message": "Look at this {file}"]).isGroupableFileMessage) + } + + func testFilesSharedBeforeTheAgreedReferenceIdAreNotGroupable() throws { + XCTAssertFalse(try self.message(["referenceId": String(repeating: "a", count: 64)]).isGroupableFileMessage) + XCTAssertFalse(try self.message(["referenceId": ""]).isGroupableFileMessage) + } + + // MARK: - Messages rendered by a widget of their own + + func testVoiceMessagesAreNotGroupable() throws { + XCTAssertFalse(try self.message(["messageType": "voice-message"]).isGroupableFileMessage) + } + + func testContactCardsAreNotGroupable() throws { + let parameters = ["file": self.fileParameter(mimetype: "text/vcard")] + XCTAssertFalse(try self.message(parameters: parameters).isGroupableFileMessage) + } + + /// Unlike web, this client has no audio player to preserve: a shared audio file is drawn with + /// the ordinary file cell, so excluding it would only split uploads that contain one + func testAudioFilesAreGroupable() throws { + let parameters = ["file": self.fileParameter(mimetype: "audio/mpeg")] + XCTAssertTrue(try self.message(parameters: parameters).isGroupableFileMessage) + } + + /// The real audio widget is the voice message, which the message type already keeps out + func testVoiceMessagesStayExcludedRegardless() throws { + let parameters = ["file": self.fileParameter(mimetype: "audio/mpeg")] + XCTAssertFalse(try self.message(["messageType": "voice-message"], parameters: parameters).isGroupableFileMessage) + } + + func testLocationsAreNotGroupable() throws { + let parameters: [String: Any] = ["object": ["type": "geo-location", "id": "geo:1,2", "name": "Somewhere"]] + XCTAssertFalse(try self.message(parameters: parameters).isGroupableFileMessage) + } + + // MARK: - Messages that are not a single plain file share + + func testMessagesSharingSeveralFilesAreNotGroupable() throws { + let parameters = ["file": self.fileParameter(), "file-1": self.fileParameter()] + XCTAssertFalse(try self.message(parameters: parameters).isGroupableFileMessage) + } + + func testMessagesWithoutAFileAreNotGroupable() throws { + XCTAssertFalse(try self.message(["message": "Hi"], parameters: [:]).isGroupableFileMessage) + } + + func testSystemMessagesAreNotGroupable() throws { + XCTAssertFalse(try self.message(["systemMessage": "call_joined"]).isGroupableFileMessage) + } + + func testDeletedMessagesAreNotGroupable() throws { + XCTAssertFalse(try self.message(["messageType": "comment_deleted"]).isGroupableFileMessage) + } + + /// Shown separately so that the edit stays visible, the same way message grouping treats it + func testEditedMessagesAreNotGroupable() throws { + XCTAssertFalse(try self.message(["lastEditTimestamp": 1_758_012_345]).isGroupableFileMessage) + } + + /// Shown separately so that the failure stays visible + func testMessagesThatFailedToSendAreNotGroupable() throws { + let message = try self.message() + message.sendingFailed = true + + XCTAssertFalse(message.isGroupableFileMessage) + } + + func testMessagesBeingDeletedAreNotGroupable() throws { + let message = try self.message() + message.isDeleting = true + + XCTAssertFalse(message.isGroupableFileMessage) + } + + // MARK: - Files drawn on a card on their own + + /// A file the server has no preview of said no more than its name next to a generic icon the + /// size of a photo, so it is drawn the way the files of a group are + func testAFileWithoutAPreviewIsDrawnOnACard() throws { + let parameters = ["file": self.fileParameter(mimetype: "text/plain")] + XCTAssertTrue(try self.message(parameters: parameters).isFileCardMessage) + } + + func testMediaKeepsItsPreview() throws { + var file = self.fileParameter() + file["preview-available"] = "yes" + + XCTAssertFalse(try self.message(parameters: ["file": file]).isFileCardMessage) + } + + func testMediaWithoutAPreviewIsDrawnOnACard() throws { + // A video the server cannot make a thumbnail of, which showed a generic icon before + let parameters = ["file": self.fileParameter(mimetype: "video/quicktime")] + XCTAssertTrue(try self.message(parameters: parameters).isFileCardMessage) + } + + func testWidgetsOfTheirOwnAreNotDrawnOnCards() throws { + XCTAssertFalse(try self.message(["messageType": "voice-message"]).isFileCardMessage) + XCTAssertFalse(try self.message(parameters: ["file": self.fileParameter(mimetype: "text/vcard")]).isFileCardMessage) + } + + /// Unlike grouping, this does not depend on the file having been shared as part of an upload + func testAFileOfNoUploadIsStillDrawnOnACard() throws { + let message = try self.message(["referenceId": ""], parameters: ["file": self.fileParameter(mimetype: "text/plain")]) + + XCTAssertFalse(message.isGroupableFileMessage) + XCTAssertTrue(message.isFileCardMessage) + } + + // MARK: - Reading the upload back + + func testFilesOfOneUploadShareTheirUploadReference() throws { + let first = try self.message(["referenceId": "\(self.uploadHash)-001"]) + let second = try self.message(["referenceId": "\(self.uploadHash)-002"]) + + XCTAssertEqual(first.fileUploadReference?.uploadHash, second.fileUploadReference?.uploadHash) + XCTAssertEqual(first.fileUploadReference?.position, 1) + XCTAssertEqual(second.fileUploadReference?.position, 2) + } +} diff --git a/ShareExtension/ShareConfirmationViewController.swift b/ShareExtension/ShareConfirmationViewController.swift index 6bdb693ba..88ca43cfc 100644 --- a/ShareExtension/ShareConfirmationViewController.swift +++ b/ShareExtension/ShareConfirmationViewController.swift @@ -870,12 +870,18 @@ private let kShareConfirmationOptionsViewHeight: CGFloat = 44 /// Builds the uploads for the items to share, compressing the images among them when the /// standard quality is asked for. internal func uploads(for shareItems: [ShareItem], quality: ChatImageQuality) async -> [ChatFileUpload] { + // Everything shared in one go belongs to the same upload, which is what lets the clients + // show these files as a single message + let uploadId = UUID().uuidString + guard quality == .standard, let directory = ChatImageCompressor.temporaryDirectory() else { NCLog.log("Sharing \(shareItems.count) files in their original quality") - return shareItems.map { self.upload(for: $0) } + return shareItems.enumerated().map { index, shareItem in + self.upload(for: shareItem, inUpload: uploadId, at: index) + } } // Only the plain values are handed to the compression, so it does not touch the share items @@ -907,11 +913,14 @@ private let kShareConfirmationOptionsViewHeight: CGFloat = 44 self.compressedImagesDirectory = directory return shareItems.enumerated().map { index, shareItem in - self.upload(for: shareItem, compressedTo: compressedImages[index]) + self.upload(for: shareItem, inUpload: uploadId, at: index, compressedTo: compressedImages[index]) } } - private func upload(for shareItem: ShareItem, compressedTo compressedImage: (url: URL, fileName: String)? = nil) -> ChatFileUpload { + private func upload(for shareItem: ShareItem, + inUpload uploadId: String, + at index: Int, + compressedTo compressedImage: (url: URL, fileName: String)? = nil) -> ChatFileUpload { var metaData = ChatFileUploadMetadata() metaData.caption = shareItem.caption metaData.silent = self.shareSilently @@ -923,6 +932,7 @@ private let kShareConfirmationOptionsViewHeight: CGFloat = 44 account: self.account) upload.metadata = metaData upload.allowUpdate = self.allowUpdate + upload.referenceId = ChatFileUpload.referenceId(uploadId: uploadId, index: index) return upload }