diff --git a/NextcloudTalk/Chat/Chat views/Giphy/GiphyPickerViewController.swift b/NextcloudTalk/Chat/Chat views/Giphy/GiphyPickerViewController.swift index c3e405a61..59de099be 100644 --- a/NextcloudTalk/Chat/Chat views/Giphy/GiphyPickerViewController.swift +++ b/NextcloudTalk/Chat/Chat views/Giphy/GiphyPickerViewController.swift @@ -60,8 +60,8 @@ class GiphyPickerViewController: UIViewController { return searchController }() - private lazy var waterfallLayout: GiphyWaterfallLayout = { - let layout = GiphyWaterfallLayout() + private lazy var waterfallLayout: WaterfallLayout = { + let layout = WaterfallLayout() layout.delegate = self return layout }() @@ -432,13 +432,13 @@ extension GiphyPickerViewController: UICollectionViewDataSource, UICollectionVie } } -// MARK: - GiphyWaterfallLayoutDelegate +// MARK: - WaterfallLayoutDelegate -extension GiphyPickerViewController: GiphyWaterfallLayoutDelegate { +extension GiphyPickerViewController: WaterfallLayoutDelegate { - func waterfallLayout(_ layout: GiphyWaterfallLayout, aspectRatioForItemAt index: Int) -> CGFloat { - guard index < self.items.count else { return Self.fallbackAspectRatio } + func waterfallLayout(_ layout: WaterfallLayout, aspectRatioForItemAt indexPath: IndexPath) -> CGFloat { + guard indexPath.item < self.items.count else { return Self.fallbackAspectRatio } - return self.items[index].aspectRatio + return self.items[indexPath.item].aspectRatio } } diff --git a/NextcloudTalk/Chat/Chat views/Giphy/GiphyWaterfallLayout.swift b/NextcloudTalk/Chat/Chat views/Giphy/GiphyWaterfallLayout.swift deleted file mode 100644 index d145e3ce9..000000000 --- a/NextcloudTalk/Chat/Chat views/Giphy/GiphyWaterfallLayout.swift +++ /dev/null @@ -1,120 +0,0 @@ -// -// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-3.0-or-later -// - -import UIKit - -protocol GiphyWaterfallLayoutDelegate: AnyObject { - /// The aspect ratio (width / height) of the item at `index`. Must not change once the item has - /// been placed, as the layout relies on that to never move already placed items. - func waterfallLayout(_ layout: GiphyWaterfallLayout, aspectRatioForItemAt index: Int) -> CGFloat -} - -/// A masonry ("waterfall") layout: items share a column width but keep their own aspect ratio, each -/// placed into the currently shortest column. Unlike a flow layout with fixed item sizes, this shows -/// GIFs without letterboxing or cropping them. -class GiphyWaterfallLayout: UICollectionViewLayout { - - weak var delegate: GiphyWaterfallLayoutDelegate? - - var interItemSpacing: CGFloat = 8 - var lineSpacing: CGFloat = 8 - var sectionInset = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8) - - /// The column count is derived from the available width to stay close to this, which keeps GIFs - /// a sensible size on a phone, an iPad sheet and a full-screen iPad window alike. - var preferredColumnWidth: CGFloat = 165 - - /// The preferred width alone would give a single full-width column on the narrowest phones - var minimumColumnCount = 2 - - /// Clamped, so that one very tall or very wide GIF cannot unbalance a whole column - private static let minAspectRatio: CGFloat = 0.55 - private static let maxAspectRatio: CGFloat = 2.2 - - private var attributesCache: [UICollectionViewLayoutAttributes] = [] - private var contentHeight: CGFloat = 0 - - private var contentWidth: CGFloat { - return self.collectionView?.bounds.width ?? 0 - } - - /// The section inset, widened by the horizontal safe area (e.g. the notch in landscape). - /// - /// Insetting the items rather than narrowing the content avoids making the content width depend - /// on the adjusted content inset, which in turn depends on the content size computed from it. - private var horizontalInsets: (left: CGFloat, right: CGFloat) { - let safeAreaInsets = self.collectionView?.safeAreaInsets ?? .zero - - return (self.sectionInset.left + safeAreaInsets.left, self.sectionInset.right + safeAreaInsets.right) - } - - override var collectionViewContentSize: CGSize { - return CGSize(width: self.contentWidth, height: self.contentHeight) - } - - override func prepare() { - super.prepare() - - self.attributesCache.removeAll(keepingCapacity: true) - self.contentHeight = 0 - - guard let collectionView = self.collectionView else { return } - - let itemCount = collectionView.numberOfItems(inSection: 0) - let insets = self.horizontalInsets - let availableWidth = self.contentWidth - insets.left - insets.right - - guard itemCount > 0, availableWidth > 0 else { return } - - // Recomputed in full on every invalidation: placement is deterministic, so existing items - // keep identical frames and no cached state can go stale (a new search can return the same - // number of items with entirely different sizes). - let columnCount = max(self.minimumColumnCount, Int((availableWidth + self.interItemSpacing) / (self.preferredColumnWidth + self.interItemSpacing))) - let itemWidth = ((availableWidth - self.interItemSpacing * CGFloat(columnCount - 1)) / CGFloat(columnCount)).rounded(.down) - - var columnBottoms = [CGFloat](repeating: self.sectionInset.top, count: columnCount) - - for item in 0.. [UICollectionViewLayoutAttributes]? { - return self.attributesCache.filter { $0.frame.intersects(rect) } - } - - override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { - guard indexPath.section == 0, indexPath.item < self.attributesCache.count else { return nil } - - return self.attributesCache[indexPath.item] - } - - override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { - guard let collectionView = self.collectionView else { return false } - - // Only the width matters (column count and item width); scrolling must not invalidate - return newBounds.width != collectionView.bounds.width - } -} diff --git a/NextcloudTalk/Chat/ChatFileDownloadObserver.swift b/NextcloudTalk/Chat/ChatFileDownloadObserver.swift new file mode 100644 index 000000000..d7f5745d0 --- /dev/null +++ b/NextcloudTalk/Chat/ChatFileDownloadObserver.swift @@ -0,0 +1,48 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation + +/// Follows the download of one file through the notifications NCChatFileController posts +class ChatFileDownloadObserver { + + enum Change { + case isDownloading(Bool) + /// nil while the size of the download is unknown + case progress(Float?) + } + + var fileParameter: NCMessageFileParameter? + + private let onChange: (Change) -> Void + private var observers: [NSObjectProtocol] = [] + + init(onChange: @escaping (Change) -> Void) { + self.onChange = onChange + + // Download progress can be reported off the main queue + self.observers.append(NotificationCenter.default.addObserver(forName: .NCChatFileControllerDidChangeIsDownloading, object: nil, queue: .main) { [weak self] notification in + guard let status = self?.status(from: notification) else { return } + + self?.onChange(.isDownloading(status.isDownloading)) + }) + + self.observers.append(NotificationCenter.default.addObserver(forName: .NCChatFileControllerDidChangeDownloadProgress, object: nil, queue: .main) { [weak self] notification in + guard let status = self?.status(from: notification) else { return } + + self?.onChange(.progress(status.canReportProgress ? Float(status.downloadProgress) : nil)) + }) + } + + deinit { + self.observers.forEach { NotificationCenter.default.removeObserver($0) } + } + + private func status(from notification: Notification) -> NCChatFileStatus? { + guard let fileParameter = self.fileParameter else { return nil } + + return NCChatFileStatus.getStatus(from: notification, for: fileParameter) + } +} diff --git a/NextcloudTalk/Media Viewer/NCMediaViewerPageViewController.swift b/NextcloudTalk/Media Viewer/NCMediaViewerPageViewController.swift index 897f28a2a..1f5689615 100644 --- a/NextcloudTalk/Media Viewer/NCMediaViewerPageViewController.swift +++ b/NextcloudTalk/Media Viewer/NCMediaViewerPageViewController.swift @@ -157,6 +157,10 @@ import SwiftyGif return videoPlayButton }() + private lazy var downloadObserver = ChatFileDownloadObserver { [weak self] change in + self?.downloadDidChange(change) + } + init(message: NCChatMessage, account: TalkAccount) { self.message = message self.account = account @@ -193,7 +197,7 @@ import SwiftyGif self.navigationItem.title = self.message.file()?.name - NotificationCenter.default.addObserver(self, selector: #selector(didChangeDownloadProgress(notification:)), name: NSNotification.Name.NCChatFileControllerDidChangeDownloadProgress, object: nil) + self.downloadObserver.fileParameter = self.message.file() self.startLoading() @@ -662,17 +666,12 @@ import SwiftyGif } } - func didChangeDownloadProgress(notification: Notification) { - DispatchQueue.main.async { - guard let fileParameter = self.message.file(), - let receivedStatus = NCChatFileStatus.getStatus(from: notification, for: fileParameter), - receivedStatus.canReportProgress - else { return } + private func downloadDidChange(_ change: ChatFileDownloadObserver.Change) { + guard case .progress(let progress?) = change else { return } - self.activityIndicator.indicatorMode = .determinate - self.activityIndicator.setProgress(Float(receivedStatus.downloadProgress), animated: true) - self.downloadIndicator.setProgress(Float(receivedStatus.downloadProgress)) - } + self.activityIndicator.indicatorMode = .determinate + self.activityIndicator.setProgress(progress, animated: true) + self.downloadIndicator.setProgress(progress) } // MARK: - NCChatFileController delegate diff --git a/NextcloudTalk/Media Viewer/NCMediaViewerViewController.swift b/NextcloudTalk/Media Viewer/NCMediaViewerViewController.swift index 5cde793ec..5fcb93ced 100644 --- a/NextcloudTalk/Media Viewer/NCMediaViewerViewController.swift +++ b/NextcloudTalk/Media Viewer/NCMediaViewerViewController.swift @@ -6,6 +6,15 @@ import Foundation import UIKit +/// Decides which media the viewer swipes to, instead of the chat messages stored for the room +@objc protocol MediaViewerMessageSource: AnyObject { + /// The closest older message to show before `message`, or nil to stop there + func mediaViewerMessage(before message: NCChatMessage) -> NCChatMessage? + + /// The closest newer message to show after `message`, or nil to stop there + func mediaViewerMessage(after message: NCChatMessage) -> NCChatMessage? +} + @objcMembers class NCMediaViewerViewController: UIViewController, UIPageViewControllerDelegate, UIPageViewControllerDataSource, @@ -15,6 +24,10 @@ import UIKit private let account: TalkAccount private let pageController = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal) private var initialMessage: NCChatMessage + private weak var messageSource: MediaViewerMessageSource? + // Resetting the data source while a swipe animates could disturb it, so that waits for the swipe to end + private var isTransitioning = false + private var needsAdjacentPagesReload = false private lazy var shareButton = { let shareButton = UIBarButtonItem(title: nil, style: .plain, target: nil, action: nil) @@ -72,10 +85,11 @@ import UIKit return showMessageButton }() - init(initialMessage: NCChatMessage, room: NCRoom, account: TalkAccount) { + init(initialMessage: NCChatMessage, room: NCRoom, account: TalkAccount, messageSource: MediaViewerMessageSource? = nil) { self.room = room self.initialMessage = initialMessage self.account = account + self.messageSource = messageSource super.init(nibName: nil, bundle: nil) } @@ -152,6 +166,20 @@ import UIKit return self.pageController.viewControllers?.first as? NCMediaViewerPageViewController } + /// For a message source that got more messages after it reported the end + func reloadAdjacentPages() { + guard !self.isTransitioning else { + self.needsAdjacentPagesReload = true + return + } + + self.needsAdjacentPagesReload = false + + // The page controller keeps a nil neighbour until its data source is set again, which leaves the current page alone + self.pageController.dataSource = nil + self.pageController.dataSource = self + } + // MARK: - PageViewController delegate func getAllFileMessages() -> RLMResults? { @@ -163,22 +191,29 @@ import UIKit return messages } + /// Whether the viewer can show the file of `message`, formats only VLC can play excluded + static func canShowMedia(of message: NCChatMessage) -> Bool { + guard let file = message.file(), let filePath = file.path else { return false } + + let fileType = file.mimetype ?? "" + let isSupportedMedia = NCUtils.isImage(fileType: fileType) || NCUtils.isVideo(fileType: fileType) + let isUnsupportedExtension = VLCKitVideoViewController.supportedFileExtensions.contains(URL(fileURLWithPath: filePath).pathExtension.lowercased()) + + return isSupportedMedia && !isUnsupportedExtension + } + func getPreviousFileMessage(from message: NCChatMessage) -> NCChatMessage? { + if let messageSource { + return messageSource.mediaViewerMessage(before: message) + } + let prevQuery = NSPredicate(format: "messageId < %ld", message.messageId) guard let queriedObjects = self.getAllFileMessages()?.objects(with: prevQuery) else { return nil } let messageObject = queriedObjects.lastObject() if let message = messageObject as? NCChatMessage { - guard let filePath = message.file().path else { - return self.getPreviousFileMessage(from: message) - } - - let fileType = message.file()?.mimetype ?? "" - let isSupportedMedia = NCUtils.isImage(fileType: fileType) || NCUtils.isVideo(fileType: fileType) - let isUnsupportedExtension = VLCKitVideoViewController.supportedFileExtensions.contains(URL(fileURLWithPath: filePath).pathExtension.lowercased()) - - if isSupportedMedia && !isUnsupportedExtension { + if Self.canShowMedia(of: message) { return message } @@ -189,20 +224,16 @@ import UIKit } func getNextFileMessage(from message: NCChatMessage) -> NCChatMessage? { + if let messageSource { + return messageSource.mediaViewerMessage(after: message) + } + let prevQuery = NSPredicate(format: "messageId > %ld", message.messageId) guard let messageObject = self.getAllFileMessages()?.objects(with: prevQuery).firstObject() else { return nil } if let message = messageObject as? NCChatMessage { - guard let filePath = message.file().path else { - return self.getNextFileMessage(from: message) - } - - let fileType = message.file()?.mimetype ?? "" - let isSupportedMedia = NCUtils.isImage(fileType: fileType) || NCUtils.isVideo(fileType: fileType) - let isUnsupportedExtension = VLCKitVideoViewController.supportedFileExtensions.contains(URL(fileURLWithPath: filePath).pathExtension.lowercased()) - - if isSupportedMedia && !isUnsupportedExtension { + if Self.canShowMedia(of: message) { return message } @@ -232,7 +263,17 @@ import UIKit return mediaPageViewController } + func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) { + self.isTransitioning = true + } + func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { + self.isTransitioning = false + + if self.needsAdjacentPagesReload { + self.reloadAdjacentPages() + } + // Update the titel of the currently shown viewController guard let mediaPageViewController = self.getCurrentPageViewController() else { return } self.navigationItem.title = mediaPageViewController.navigationItem.title diff --git a/NextcloudTalk/Network/NCAPIController.swift b/NextcloudTalk/Network/NCAPIController.swift index 0d117f93d..0f7292993 100644 --- a/NextcloudTalk/Network/NCAPIController.swift +++ b/NextcloudTalk/Network/NCAPIController.swift @@ -2874,7 +2874,7 @@ class NCAPIController: NSObject, NKCommonDelegate { var result: [String: [NCChatMessage]] = [:] for (key, value) in dataDict { - result[key] = value.compactMap({ NCChatMessage(dictionary: $0) }) + result[key] = value.compactMap({ NCChatMessage(dictionary: $0, andAccountId: account.accountId) }) } completionBlock(result, nil) @@ -2908,7 +2908,7 @@ class NCAPIController: NSObject, NKCommonDelegate { return apiSessionManager.getOcs(urlString, account: account, parameters: parameters) { ocsResponse, ocsError in if let dataDict = ocsResponse?.dataDict as? [String: [String: Any]] { let headerLastKnownMessage = Int(ocsResponse?.value(forHTTPHeaderField: "x-chat-last-given")) ?? -1 - completionBlock(dataDict.compactMap({ NCChatMessage(dictionary: $0.value) }), headerLastKnownMessage, nil) + completionBlock(dataDict.compactMap({ NCChatMessage(dictionary: $0.value, andAccountId: account.accountId) }), headerLastKnownMessage, nil) } else { completionBlock(nil, -1, ocsError) } diff --git a/NextcloudTalk/Rooms/RoomInfo/RoomInfoSharedItemsSection.swift b/NextcloudTalk/Rooms/RoomInfo/RoomInfoSharedItemsSection.swift index c74ffbbc6..a1837a938 100644 --- a/NextcloudTalk/Rooms/RoomInfo/RoomInfoSharedItemsSection.swift +++ b/NextcloudTalk/Rooms/RoomInfo/RoomInfoSharedItemsSection.swift @@ -18,7 +18,7 @@ struct RoomInfoSharedItemsSection: View { return Section(header: Text("Shared items")) { Button(action: { - hostingWrapper.pushViewController(RoomSharedItemsTableViewController(room: room), animated: true) + hostingWrapper.pushViewController(RoomSharedItemsViewController(room: room), animated: true) }, label: { // Add disclosure chevron on button NavigationLink(destination: EmptyView(), label: { diff --git a/NextcloudTalk/Rooms/RoomSharedItemsTableViewController.swift b/NextcloudTalk/Rooms/RoomSharedItemsTableViewController.swift deleted file mode 100644 index e19611093..000000000 --- a/NextcloudTalk/Rooms/RoomSharedItemsTableViewController.swift +++ /dev/null @@ -1,500 +0,0 @@ -// -// SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-3.0-or-later -// - -import UIKit -import QuickLook -import PassKit - -@objcMembers class RoomSharedItemsTableViewController: UITableViewController, - QLPreviewControllerDelegate, - QLPreviewControllerDataSource, - VLCKitVideoViewControllerDelegate { - - let room: NCRoom - let itemsOverviewLimit: Int = 1 - let itemLimit: Int = 100 - var sharedItemsOverview: [String: [NCChatMessage]] = [:] - var currentItems: [NCChatMessage] = [] - var currentItemType: String = "all" - var currentLastItemId: Int = -1 - var sharedItemsBackgroundView: PlaceholderView = PlaceholderView(for: .insetGrouped) - var previewControllerFilePath: String = "" - var isPreviewControllerShown: Bool = false - - weak var previewChatViewController: ContextChatViewController? - weak var previewNavigationChatViewController: NCNavigationController? - - init(room: NCRoom) { - self.room = room - super.init(nibName: "RoomSharedItemsTableViewController", bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - - NCAppBranding.styleViewController(self) - - self.navigationItem.title = NSLocalizedString("Shared items", comment: "") - - self.tableView.separatorInset = UIEdgeInsets(top: 0, left: 64, bottom: 0, right: 0) - self.tableView.register(UINib(nibName: DirectoryTableViewCell.nibName, bundle: nil), forCellReuseIdentifier: DirectoryTableViewCell.identifier) - - self.hideShowMoreButton() - self.getItemsOverview() - } - - func availableItemTypes() -> [String] { - var availableItemTypes: [String] = [] - for itemType in sharedItemsOverview.keys { - guard let items = sharedItemsOverview[itemType] else {continue} - if !items.isEmpty { - availableItemTypes.append(itemType) - } - } - return availableItemTypes.sorted(by: { $0 < $1 }) - } - - func getItemsForItemType(itemType: String) { - guard let account = room.account else { return } - - showFetchingItemsPlaceholderView() - - NCAPIController.sharedInstance() - .getSharedItems(ofType: itemType, fromLastMessageId: currentLastItemId, inRoom: room.token, - withLimit: itemLimit, forAccount: account) { items, lastItemId, error in - if error == nil, let sharedItems = items as? [NCChatMessage] { - // Remove deleted files - var filteredItems: [NCChatMessage] = [] - for message in sharedItems { - if message.systemMessage == "file_shared" && message.file() == nil {continue} - filteredItems.append(message) - } - // Sort received items - let sortedItems = filteredItems.sorted(by: { $0.messageId > $1.messageId }) - // Set or append items - if self.currentLastItemId > 0 { - self.currentItems.append(contentsOf: sortedItems) - } else { - self.currentItems = sortedItems - } - // Set new last item id - self.currentLastItemId = lastItemId - // Show ir hide "Show more" button - if sharedItems.count == self.itemLimit { - self.showShowMoreButton() - } else { - self.hideShowMoreButton() - } - // Load items - self.tableView.reloadData() - } else { - self.hideShowMoreButton() - } - self.hideFetchingItemsPlaceholderView() - } - } - - func getItemsOverview() { - guard let account = room.account else { return } - - showFetchingItemsPlaceholderView() - - NCAPIController.sharedInstance() - .getSharedItemsOverview(inRoom: room.token, withLimit: itemsOverviewLimit, forAccount: account) { itemsOverview, error in - if error == nil { - self.sharedItemsOverview = itemsOverview ?? [:] - let availableItemTypes = self.availableItemTypes() - if availableItemTypes.isEmpty { - self.hideFetchingItemsPlaceholderView() - } else if availableItemTypes.contains(kSharedItemTypeMedia) { - self.setupViewForItemType(itemType: kSharedItemTypeMedia) - } else if availableItemTypes.contains(kSharedItemTypeFile) { - self.setupViewForItemType(itemType: kSharedItemTypeFile) - } else if let firstItemType = availableItemTypes.first { - self.setupViewForItemType(itemType: firstItemType) - } - } else { - self.hideFetchingItemsPlaceholderView() - } - } - } - - func setupViewForItemType(itemType: String) { - currentItemType = itemType - currentItems = [] - currentLastItemId = -1 - hideShowMoreButton() - tableView.reloadData() - setupTitleButtonForItemType(itemType: itemType) - getItemsForItemType(itemType: itemType) - } - - func setupTitleButtonForItemType(itemType: String) { - let itemTypeSelectorButton = UIButton(type: .custom) - let buttonTitle = nameForItemType(itemType: itemType) + " ▼" - itemTypeSelectorButton.setTitle(buttonTitle, for: .normal) - itemTypeSelectorButton.titleLabel?.font = UIFont.systemFont(ofSize: 17, weight: .medium) - itemTypeSelectorButton.setTitleColor(NCAppBranding.themeTextColor(), for: .normal) - if #available(iOS 26.0, *) { - itemTypeSelectorButton.setTitleColor(.label, for: .normal) - } else { - itemTypeSelectorButton.setTitleColor(NCAppBranding.themeTextColor(), for: .normal) - } - self.navigationItem.titleView = itemTypeSelectorButton - - var menuActions: [UIAction] = [] - - for itemType in availableItemTypes() { - let itemTypeName = nameForItemType(itemType: itemType) - let action = UIAction(title: itemTypeName, image: nil) { [unowned self] _ in - self.setupViewForItemType(itemType: itemType) - } - - if itemType == currentItemType { - action.state = .on - } - - menuActions.append(action) - } - - itemTypeSelectorButton.showsMenuAsPrimaryAction = true - itemTypeSelectorButton.menu = UIMenu(children: menuActions) - } - - func showFetchingItemsPlaceholderView() { - sharedItemsBackgroundView.placeholderView.isHidden = true - sharedItemsBackgroundView.setImage(UIImage(systemName: "photo.on.rectangle.angled")) - sharedItemsBackgroundView.placeholderImage.contentMode = .scaleAspectFit - sharedItemsBackgroundView.placeholderTextView.text = NSLocalizedString("No shared items", comment: "") - sharedItemsBackgroundView.loadingView.startAnimating() - sharedItemsBackgroundView.loadingView.isHidden = false - self.tableView.backgroundView = sharedItemsBackgroundView - } - - func hideFetchingItemsPlaceholderView() { - sharedItemsBackgroundView.loadingView.stopAnimating() - sharedItemsBackgroundView.loadingView.isHidden = true - sharedItemsBackgroundView.placeholderView.isHidden = !currentItems.isEmpty - } - - func showShowMoreButton() { - let showMoreButton = UIButton(frame: CGRect(origin: .zero, size: CGSize(width: self.tableView.frame.width, height: 40))) - showMoreButton.titleLabel?.font = .systemFont(ofSize: 15) - showMoreButton.setTitleColor(.systemBlue, for: .normal) - showMoreButton.setTitle(NSLocalizedString("Show more…", comment: ""), for: .normal) - showMoreButton.addTarget(self, action: #selector(showMoreButtonClicked), for: .touchUpInside) - self.tableView.tableFooterView = showMoreButton - } - - func hideShowMoreButton() { - self.tableView.tableFooterView = UIView() - } - - func showMoreButtonClicked() { - let loadingMoreView = UIActivityIndicatorView(frame: CGRect(origin: .zero, size: CGSize(width: 40, height: 40))) - loadingMoreView.color = .darkGray - loadingMoreView.startAnimating() - self.tableView.tableFooterView = loadingMoreView - getItemsForItemType(itemType: currentItemType) - } - - func nameForItemType(itemType: String) -> String { - switch itemType { - case kSharedItemTypeAudio: - return NSLocalizedString("Audios", comment: "") - case kSharedItemTypeDeckcard: - return NSLocalizedString("Deck cards", comment: "") - case kSharedItemTypeFile: - return NSLocalizedString("Files", comment: "") - case kSharedItemTypeMedia: - return NSLocalizedString("Media", comment: "Category of shared items in a conversation: images and videos") - case kSharedItemTypeLocation: - return NSLocalizedString("Locations", comment: "") - case kSharedItemTypeOther: - return NSLocalizedString("Others", comment: "Category of shared items in a conversation, for everything without its own category") - case kSharedItemTypeVoice: - return NSLocalizedString("Voice messages", comment: "") - case kSharedItemTypePoll: - return NSLocalizedString("Polls", comment: "") - case kSharedItemTypeRecording: - return NSLocalizedString("Recordings", comment: "") - case kSharedItemTypePinned: - return NSLocalizedString("Pinned messages", comment: "") - default: - return NSLocalizedString("Shared items", comment: "") - } - } - - func imageForMessage(message: NCChatMessage) -> UIImage { - var image = UIImage(systemName: "bubble") - - if message.file() != nil { - let imageName = NCUtils.previewImage(forMimeType: message.file().mimetype) - image = UIImage(named: imageName) - } - - if message.geoLocation() != nil { - image = UIImage(systemName: "mappin") - } - - if message.deckCard() != nil { - image = UIImage(named: "deck-item") - } - - if message.poll != nil { - image = UIImage(systemName: "chart.bar") - } - - return image ?? UIImage() - } - - // MARK: - File downloader - - func downloadFileForCell(cell: DirectoryTableViewCell, file: NCMessageFileParameter) { - guard let account = self.room.account else { return } - - cell.fileParameter = file - - ChatFileDownloader.shared.downloadFile(withFileId: file.parameterId, fromAccount: account) { [weak self] result in - guard let self else { return } - - switch result { - case .success(let fileStatus): - self.didLoadFile(with: fileStatus) - case .failure(.fileUnavailable(let errorDescription)), .failure(.downloadFailed(let errorDescription)): - self.didFailLoadingFile(with: errorDescription) - case .failure(.cancelled): - break - } - } - } - - private func didLoadFile(with fileStatus: NCChatFileStatus) { - DispatchQueue.main.async { - if self.isPreviewControllerShown { - return - } - - guard let fileLocalPath = fileStatus.fileLocalPath else { return } - - self.previewControllerFilePath = fileLocalPath - self.isPreviewControllerShown = true - - let fileExtension = URL(fileURLWithPath: fileLocalPath).pathExtension.lowercased() - - // Use VLCKitVideoViewController for file formats unsupported by the native PreviewController - if VLCKitVideoViewController.supportedFileExtensions.contains(fileExtension) { - let vlcViewController = VLCKitVideoViewController(filePath: fileLocalPath) - vlcViewController.delegate = self - vlcViewController.modalPresentationStyle = .fullScreen - - self.present(vlcViewController, animated: true) - - return - } - - // Use PKAddPassesViewController for Apple Wallet passes - if fileExtension == "pkpass" { - if let passData = try? Data(contentsOf: URL(fileURLWithPath: fileLocalPath)), - let pass = try? PKPass(data: passData), - let addPassVC = PKAddPassesViewController(pass: pass) { - self.present(addPassVC, animated: true) - self.isPreviewControllerShown = false - return - } - } - - let previewController = QLPreviewController() - previewController.dataSource = self - previewController.delegate = self - self.present(previewController, animated: true) - } - } - - private func didFailLoadingFile(with errorDescription: String) { - let alertTitle = NSLocalizedString("Unable to load file", comment: "") - let alert = UIAlertController( - title: alertTitle, - message: errorDescription, - preferredStyle: .alert) - - let okAction = UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .default, handler: nil) - alert.addAction(okAction) - - self.present(alert, animated: true, completion: nil) - } - - func numberOfPreviewItems(in controller: QLPreviewController) -> Int { - return 1 - } - - func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { - return NSURL(fileURLWithPath: previewControllerFilePath) - } - - func previewControllerDidDismiss(_ controller: QLPreviewController) { - isPreviewControllerShown = false - } - - func vlckitVideoViewControllerDismissed(_ controller: VLCKitVideoViewController) { - isPreviewControllerShown = false - } - - // MARK: - Locations - - func presentLocation(location: GeoLocationRichObject) { - let mapViewController = MapViewController(geoLocationRichObject: location) - let navigationViewController = NCNavigationController(rootViewController: mapViewController) - self.present(navigationViewController, animated: true, completion: nil) - } - - // MARK: - Polls - - func presentPoll(pollId: Int) { - let pollViewController = PollVotingView(room: room) - let navigationViewController = NCNavigationController(rootViewController: pollViewController) - self.present(navigationViewController, animated: true, completion: nil) - - let activeAccount = NCDatabaseManager.sharedInstance().activeAccount() - NCAPIController.sharedInstance().getPoll(withId: pollId, inRoom: room.token, forAccount: activeAccount) { poll, error in - if let poll = poll, error == nil { - pollViewController.updatePoll(poll: poll) - } - } - } - - // MARK: - Other files - - func openLink(link: String) { - NCUtils.openLinkInBrowser(link: link) - } - - // MARK: - Table view data source - - override func numberOfSections(in tableView: UITableView) -> Int { - return 1 - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return currentItems.count - } - - override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return DirectoryTableViewCell.cellHeight - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: DirectoryTableViewCell.identifier) as? DirectoryTableViewCell ?? - DirectoryTableViewCell(style: .default, reuseIdentifier: DirectoryTableViewCell.identifier) - - let message = currentItems[indexPath.row] - - if let file = message.file() { - cell.fileNameLabel?.text = file.name - } else { - cell.fileNameLabel?.text = message.parsedMessage().string - } - - var infoLabelText = NCUtils.relativeTimeFromDate(date: Date(timeIntervalSince1970: Double(message.timestamp))) - if !message.actorDisplayName.isEmpty { - infoLabelText += " ⸱ " + message.actorDisplayName - } - if let file = message.file(), let size = file.size, size > 0 { - let formatter = ByteCountFormatter() - formatter.countStyle = .file - let sizeString = formatter.string(fromByteCount: Int64(size)) - infoLabelText += " ⸱ " + sizeString - } - cell.fileInfoLabel?.text = infoLabelText - - let image = imageForMessage(message: message) - cell.fileImageView?.image = image - cell.fileImageView?.tintColor = .secondaryLabel - if message.file()?.previewAvailable != nil { - cell.fileImageView?.setPreview(forFileId: message.file().parameterId, withWidth: 40, withHeight: 40, usingAccount: .activeAccount) - } - return cell - } - - override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { - // The preview shows the context of the message, which is not available without the context endpoint - guard self.room.supportsMessageContext else { return nil } - - return UIContextMenuConfiguration(identifier: indexPath as NSCopying, previewProvider: { - - // Init the BaseChatViewController without message to directly show a preview - if let account = self.room.account, let chatViewController = ContextChatViewController(forRoom: self.room, withAccount: account, withMessage: [], withHighlightId: 0) { - self.previewChatViewController = chatViewController - - // Fetch the context of the message and update the BaseChatViewController - let message = self.currentItems[indexPath.row] - chatViewController.showContext(ofMessageId: message.messageId, withLimit: 50, withCloseButton: false) - - let navController = NCNavigationController(rootViewController: chatViewController) - self.previewNavigationChatViewController = navController - - return navController - } - - return nil - }, actionProvider: { _ in - UIMenu(children: [UIAction(title: NSLocalizedString("Open", comment: "Context menu action to open a shared file")) { _ in - DispatchQueue.main.async { - self.presentPreviewChatViewController() - } - }]) - }) - } - - override func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { - animator.addAnimations { - self.presentPreviewChatViewController() - } - } - - func presentPreviewChatViewController() { - guard let previewNavigationChatViewController = self.previewNavigationChatViewController, - let previewChatViewController = self.previewChatViewController - else { return } - - self.present(previewNavigationChatViewController, animated: false) - - previewChatViewController.navigationItem.rightBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Close", comment: ""), primaryAction: UIAction { [weak previewChatViewController] _ in - previewChatViewController?.dismiss(animated: true) - }) - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let cell = tableView.cellForRow(at: indexPath) as? DirectoryTableViewCell ?? DirectoryTableViewCell() - let message = currentItems[indexPath.row] - - self.tableView.deselectRow(at: indexPath, animated: true) - - switch currentItemType { - case kSharedItemTypeMedia, kSharedItemTypeFile, kSharedItemTypeVoice, kSharedItemTypeAudio, kSharedItemTypeRecording: - if let file = message.file() { - downloadFileForCell(cell: cell, file: file) - } - case kSharedItemTypeLocation: - if let geoLocation = message.geoLocation() { - presentLocation(location: GeoLocationRichObject(from: geoLocation)) - } - case kSharedItemTypeDeckcard, kSharedItemTypeOther: - if let link = message.objectShareLink() { - openLink(link: link) - } - case kSharedItemTypePoll: - if let poll = message.poll, let pollId = Int(poll.parameterId) { - presentPoll(pollId: pollId) - } - default: - return - } - } -} diff --git a/NextcloudTalk/Rooms/RoomSharedItemsTableViewController.xib b/NextcloudTalk/Rooms/RoomSharedItemsTableViewController.xib deleted file mode 100644 index 01880255f..000000000 --- a/NextcloudTalk/Rooms/RoomSharedItemsTableViewController.xib +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/NextcloudTalk/Rooms/RoomSharedItemsViewController.swift b/NextcloudTalk/Rooms/RoomSharedItemsViewController.swift new file mode 100644 index 000000000..ce3ae8ea9 --- /dev/null +++ b/NextcloudTalk/Rooms/RoomSharedItemsViewController.swift @@ -0,0 +1,1104 @@ +// +// SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import UIKit +import AVFoundation +import QuickLook +import PassKit + +class RoomSharedItemsViewController: UIViewController, + UITableViewDataSource, + UITableViewDelegate, + UICollectionViewDataSource, + UICollectionViewDelegate, + WaterfallLayoutDelegate, + MediaViewerMessageSource, + SharedAudioCellDelegate, + AVAudioPlayerDelegate, + QLPreviewControllerDelegate, + QLPreviewControllerDataSource, + VLCKitVideoViewControllerDelegate { + + private struct ItemSection { + let month: DateComponents? + let title: String? + var items: [NCChatMessage] + } + + private static let itemTypeOrder = [kSharedItemTypeMedia, kSharedItemTypeFile, kSharedItemTypeVoice, kSharedItemTypeAudio, + kSharedItemTypeRecording, kSharedItemTypeLocation, kSharedItemTypePoll, + kSharedItemTypeDeckcard, kSharedItemTypePinned, kSharedItemTypeOther] + + let room: NCRoom + private let itemsOverviewLimit: Int = 1 + private let itemLimit: Int = 100 + + /// Reserved below the content for the loading indicator, also how close to the end it shows + private static let loadingMoreIndicatorHeight: CGFloat = 44.0 + + private static let mediaViewerPrefetchDistance = 10 + + private static let fileItemTypes: Set = [kSharedItemTypeMedia, kSharedItemTypeFile, kSharedItemTypeAudio, + kSharedItemTypeVoice, kSharedItemTypeRecording] + + private static let monthFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.setLocalizedDateFormatFromTemplate("MMMMyyyy") + return formatter + }() + + private var sharedItemsOverview: [String: [NCChatMessage]] = [:] + private var currentItemType: String = "all" + /// All loaded items of the current type, newest first + private var currentItems: [NCChatMessage] = [] + private var sections: [ItemSection] = [] + private var currentLastItemId: Int = -1 + // False until a type is picked, so scroll callbacks before the overview arrives don't request the "all" placeholder type + private var hasMore = false + private var isLoading = false + // Bumped on every type change, so responses for the previous type are ignored + private var generation = 0 + private var loadTask: URLSessionDataTask? + + private var audioPlayer: AVAudioPlayer? + private var audioPlayerMessageId: Int? + // The last play request, so a slow download doesn't start playing over a newer one + private var pendingAudioMessageId: Int? + private var audioProgressTimer: Timer? + + private var previewControllerFilePath: String = "" + private var isPreviewControllerShown: Bool = false + + weak var previewChatViewController: ContextChatViewController? + weak var previewNavigationChatViewController: NCNavigationController? + + private weak var mediaViewController: NCMediaViewerViewController? + private var mediaViewerReachedLoadedEnd = false + + private var isShowingMedia: Bool { + return self.currentItemType == kSharedItemTypeMedia + } + + private var activeScrollView: UIScrollView { + return self.isShowingMedia ? self.collectionView : self.tableView + } + + private lazy var sharedItemsBackgroundView = PlaceholderView(for: .insetGrouped) + + private lazy var tableView: UITableView = { + let tableView = UITableView(frame: .zero, style: .insetGrouped) + tableView.dataSource = self + tableView.delegate = self + tableView.separatorInset = UIEdgeInsets(top: 0, left: 64, bottom: 0, right: 0) + tableView.tableFooterView = UIView() + tableView.register(UINib(nibName: DirectoryTableViewCell.nibName, bundle: nil), forCellReuseIdentifier: DirectoryTableViewCell.identifier) + tableView.register(SharedFileCell.self, forCellReuseIdentifier: SharedFileCell.identifier) + tableView.register(SharedAudioCell.self, forCellReuseIdentifier: SharedAudioCell.identifier) + tableView.register(SharedLocationCell.self, forCellReuseIdentifier: SharedLocationCell.identifier) + tableView.estimatedRowHeight = 80 + return tableView + }() + + private lazy var waterfallLayout: WaterfallLayout = { + let layout = WaterfallLayout() + layout.delegate = self + layout.interItemSpacing = 12 + layout.lineSpacing = 12 + layout.sectionInset = UIEdgeInsets(top: 8, left: 12, bottom: 8, right: 12) + return layout + }() + + private lazy var collectionView: UICollectionView = { + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: self.waterfallLayout) + collectionView.backgroundColor = .systemGroupedBackground + collectionView.alwaysBounceVertical = true + collectionView.dataSource = self + collectionView.delegate = self + collectionView.register(SharedMediaCell.self, forCellWithReuseIdentifier: SharedMediaCell.identifier) + collectionView.register(SharedMediaSectionHeaderView.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: SharedMediaSectionHeaderView.identifier) + return collectionView + }() + + private lazy var typeFilterView: RoomTagsFilterView = { + let filterView = RoomTagsFilterView(frame: .zero) + // The default chip background is the grouped background color in light mode + filterView.unselectedChipBackgroundColor = .secondarySystemGroupedBackground + filterView.onChipSelected = { [weak self] itemType in + guard let self, itemType != self.currentItemType else { return } + + self.setupViewForItemType(itemType: itemType) + } + return filterView + }() + + // Collapsed until the available types are known + private lazy var typeFilterViewHeightConstraint = self.typeFilterView.heightAnchor.constraint(equalToConstant: 0) + + /// Pinned to the screen, a footer would move while scrolling and as items are appended + private lazy var loadingMoreIndicator: UIActivityIndicatorView = { + let indicator = UIActivityIndicatorView(style: .medium) + indicator.hidesWhenStopped = true + indicator.color = .secondaryLabel + return indicator + }() + + init(room: NCRoom) { + self.room = room + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + NCAppBranding.styleViewController(self) + + self.navigationItem.title = NSLocalizedString("Shared items", comment: "") + self.view.backgroundColor = .systemGroupedBackground + + self.view.addSubview(self.typeFilterView) + self.view.addSubview(self.tableView) + self.view.addSubview(self.collectionView) + self.view.addSubview(self.loadingMoreIndicator) + + self.typeFilterView.translatesAutoresizingMaskIntoConstraints = false + self.tableView.translatesAutoresizingMaskIntoConstraints = false + self.collectionView.translatesAutoresizingMaskIntoConstraints = false + self.loadingMoreIndicator.translatesAutoresizingMaskIntoConstraints = false + + // Set once, so the content never shifts when the indicator comes and goes + self.tableView.contentInset.bottom = Self.loadingMoreIndicatorHeight + self.collectionView.contentInset.bottom = Self.loadingMoreIndicatorHeight + + NSLayoutConstraint.activate([ + self.typeFilterView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor), + self.typeFilterView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + self.typeFilterView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), + self.typeFilterViewHeightConstraint, + + self.tableView.topAnchor.constraint(equalTo: self.typeFilterView.bottomAnchor), + self.tableView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + self.tableView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + self.tableView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), + + self.collectionView.topAnchor.constraint(equalTo: self.typeFilterView.bottomAnchor), + self.collectionView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + self.collectionView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + self.collectionView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), + + self.loadingMoreIndicator.centerXAnchor.constraint(equalTo: self.view.centerXAnchor), + self.loadingMoreIndicator.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, constant: -12) + ]) + + self.collectionView.isHidden = true + self.getItemsOverview() + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + coordinator.animate(alongsideTransition: { _ in + self.collectionView.collectionViewLayout.invalidateLayout() + }) + } + + // Not viewWillDisappear, an interactive back swipe can still be cancelled there + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + // Room info is often a sheet, swiping that down only marks the presented controller further up + var viewController: UIViewController? = self + while let current = viewController { + if current.isMovingFromParent || current.isBeingDismissed { + self.stopAudio() + return + } + + viewController = current.parent + } + } + + deinit { + // A scheduled timer is kept alive by its run loop, not by us + self.audioProgressTimer?.invalidate() + } + + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + + // The layout insets for the horizontal safe area, which can change without the width changing + self.collectionView.collectionViewLayout.invalidateLayout() + } + + func availableItemTypes() -> [String] { + let availableItemTypes = sharedItemsOverview.filter { !$0.value.isEmpty }.keys + + // Types this version doesn't know yet go last + return availableItemTypes.sorted { lhs, rhs in + let lhsIndex = Self.itemTypeOrder.firstIndex(of: lhs) ?? Self.itemTypeOrder.count + let rhsIndex = Self.itemTypeOrder.firstIndex(of: rhs) ?? Self.itemTypeOrder.count + + return lhsIndex != rhsIndex ? lhsIndex < rhsIndex : lhs < rhs + } + } + + // MARK: - Loading + + func getItemsOverview() { + guard let account = room.account else { return } + + showFetchingItemsPlaceholderView() + + NCAPIController.sharedInstance() + .getSharedItemsOverview(inRoom: room.token, withLimit: itemsOverviewLimit, forAccount: account) { itemsOverview, error in + if error == nil { + self.sharedItemsOverview = itemsOverview ?? [:] + if let firstItemType = self.availableItemTypes().first { + self.setupViewForItemType(itemType: firstItemType) + } else { + self.hideFetchingItemsPlaceholderView() + } + } else { + self.hideFetchingItemsPlaceholderView() + } + } + } + + func setupViewForItemType(itemType: String) { + self.loadTask?.cancel() + self.stopAudio() + + self.generation += 1 + self.currentItemType = itemType + self.currentLastItemId = -1 + // Resetting the scroll position below calls scrollViewDidScroll, which must not start loading yet + self.hasMore = false + self.isLoading = false + + self.removeAllItems() + + self.tableView.isHidden = self.isShowingMedia + self.collectionView.isHidden = !self.isShowingMedia + self.tableView.backgroundView = nil + self.collectionView.backgroundView = nil + self.hasMore = true + + let chips = self.availableItemTypes().map { TagFilterChip(id: $0, title: self.nameForItemType(itemType: $0)) } + self.typeFilterView.update(chips: chips, selectedChipId: itemType) + // Also picks up text size changes since the last switch + self.typeFilterViewHeightConstraint.constant = RoomTagsFilterView.viewHeight + loadMoreItems() + } + + private func removeAllItems() { + // Read first, so UIKit checks the delete against its real counts even if it never loaded them + let removedSectionCount = self.collectionView.numberOfSections + + self.currentItems = [] + self.sections = [] + + self.tableView.reloadData() + self.tableView.setContentOffset(CGPoint(x: 0, y: -self.tableView.adjustedContentInset.top), animated: false) + + guard removedSectionCount > 0 else { return } + + // Not reloadData(), it only applies at the next layout pass, after the new type's first insert + UIView.performWithoutAnimation { + self.collectionView.deleteSections(IndexSet(0.. rhs.pinnedAt + } + + return lhs.messageId > rhs.messageId + } + + self.currentLastItemId = lastItemId + // The server filters after applying the limit, so only an empty page marks the end + self.hasMore = !sharedItems.isEmpty && lastItemId > 0 + + self.appendItems(sortedItems) + } else { + self.hasMore = false + } + + // Also when nothing was appended, the viewer then asks again and requests the next page itself + if self.mediaViewerReachedLoadedEnd { + self.mediaViewerReachedLoadedEnd = false + self.mediaViewController?.reloadAdjacentPages() + } + + // A page may not fill the screen or be filtered out entirely, and nothing else would request the next one + self.loadMoreItemsIfNearBottom() + + // While nothing is shown yet, otherwise "No shared items" flashes up before the next page arrives + if !self.isLoading || !self.currentItems.isEmpty { + self.hideFetchingItemsPlaceholderView() + } + + self.updateLoadingMoreIndicator() + } + } + + private func appendItems(_ items: [NCChatMessage]) { + guard !items.isEmpty else { return } + + self.currentItems.append(contentsOf: items) + + // Read first, so UIKit checks the insert against its real counts even if it never loaded them + let oldSectionCount = self.isShowingMedia ? self.collectionView.numberOfSections : self.sections.count + var insertedIndexPaths: [IndexPath] = [] + + for message in items { + let date = Date(timeIntervalSince1970: TimeInterval(message.timestamp)) + // Pinned messages are ordered by when they were pinned, months would chop them up + let month = self.currentItemType == kSharedItemTypePinned ? nil : Calendar.current.dateComponents([.year, .month], from: date) + + if let lastSection = self.sections.indices.last, self.sections[lastSection].month == month { + self.sections[lastSection].items.append(message) + + if lastSection < oldSectionCount { + insertedIndexPaths.append(IndexPath(item: self.sections[lastSection].items.count - 1, section: lastSection)) + } + } else { + let title = month == nil ? nil : Self.monthFormatter.string(from: date) + self.sections.append(ItemSection(month: month, title: title, items: [message])) + } + } + + guard self.isShowingMedia else { + self.tableView.reloadData() + return + } + + self.collectionView.performBatchUpdates { + self.collectionView.insertSections(IndexSet(oldSectionCount.. String { + switch itemType { + case kSharedItemTypeAudio: + return NSLocalizedString("Audios", comment: "") + case kSharedItemTypeDeckcard: + return NSLocalizedString("Deck cards", comment: "") + case kSharedItemTypeFile: + return NSLocalizedString("Files", comment: "") + case kSharedItemTypeMedia: + return NSLocalizedString("Media", comment: "Category of shared items in a conversation: images and videos") + case kSharedItemTypeLocation: + return NSLocalizedString("Locations", comment: "") + case kSharedItemTypeOther: + return NSLocalizedString("Others", comment: "Category of shared items in a conversation, for everything without its own category") + case kSharedItemTypeVoice: + return NSLocalizedString("Voice messages", comment: "") + case kSharedItemTypePoll: + return NSLocalizedString("Polls", comment: "") + case kSharedItemTypeRecording: + return NSLocalizedString("Recordings", comment: "") + case kSharedItemTypePinned: + return NSLocalizedString("Pinned messages", comment: "") + default: + return NSLocalizedString("Shared items", comment: "") + } + } + + func imageForMessage(message: NCChatMessage) -> UIImage { + var image = UIImage(systemName: "bubble") + + if message.file() != nil { + let imageName = NCUtils.previewImage(forMimeType: message.file().mimetype) + image = UIImage(named: imageName) + } + + if message.geoLocation() != nil { + image = UIImage(systemName: "mappin") + } + + if message.deckCard() != nil { + image = UIImage(named: "deck-item") + } + + if message.poll != nil { + image = UIImage(systemName: "chart.bar") + } + + return image ?? UIImage() + } + + // MARK: - Media + + private func aspectRatio(of message: NCChatMessage) -> CGFloat { + guard let file = message.file(), file.previewAvailable else { return 1 } + + if file.previewImageWidth > 0, file.previewImageHeight > 0 { + return CGFloat(file.previewImageWidth) / CGFloat(file.previewImageHeight) + } + + if file.width > 0, file.height > 0 { + return CGFloat(file.width) / CGFloat(file.height) + } + + return 1 + } + + private func message(at indexPath: IndexPath) -> NCChatMessage? { + guard indexPath.section < self.sections.count, + indexPath.item < self.sections[indexPath.section].items.count + else { return nil } + + return self.sections[indexPath.section].items[indexPath.item] + } + + private func presentMedia(of message: NCChatMessage) { + guard let account = self.room.account else { return } + + // Formats only VLC can play keep going through the download + guard NCMediaViewerViewController.canShowMedia(of: message) else { + if let file = message.file() { + downloadFile(file: file) + } + + return + } + + let mediaViewController = NCMediaViewerViewController(initialMessage: message, room: self.room, account: account, messageSource: self) + let navController = CustomPresentableNavigationController(rootViewController: mediaViewController) + + self.mediaViewController = mediaViewController + self.mediaViewerReachedLoadedEnd = false + self.stopAudio() + self.present(navController, interactiveDismissalType: .standard) + } + + // MARK: - MediaViewerMessageSource + + func mediaViewerMessage(before message: NCChatMessage) -> NCChatMessage? { + guard let index = self.currentItems.firstIndex(where: { $0.messageId == message.messageId }) else { return nil } + + if self.currentItems.count - index <= Self.mediaViewerPrefetchDistance { + self.loadMoreItems() + } + + // The list is newest first, so older media come after + let olderMessage = self.currentItems[(index + 1)...].first(where: { NCMediaViewerViewController.canShowMedia(of: $0) }) + + if olderMessage == nil, self.hasMore { + self.mediaViewerReachedLoadedEnd = true + // The prefetch above only looks at the distance, which media the viewer can't show may exceed + self.loadMoreItems() + } + + return olderMessage + } + + func mediaViewerMessage(after message: NCChatMessage) -> NCChatMessage? { + guard let index = self.currentItems.firstIndex(where: { $0.messageId == message.messageId }) else { return nil } + + return self.currentItems[.. Int { + return 1 + } + + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { + return NSURL(fileURLWithPath: previewControllerFilePath) + } + + func previewControllerDidDismiss(_ controller: QLPreviewController) { + isPreviewControllerShown = false + } + + func vlckitVideoViewControllerDismissed(_ controller: VLCKitVideoViewController) { + isPreviewControllerShown = false + } + + // MARK: - Locations + + func presentLocation(location: GeoLocationRichObject) { + let mapViewController = MapViewController(geoLocationRichObject: location) + let navigationViewController = NCNavigationController(rootViewController: mapViewController) + self.present(navigationViewController, animated: true, completion: nil) + } + + // MARK: - Polls + + func presentPoll(pollId: Int) { + let pollViewController = PollVotingView(room: room) + let navigationViewController = NCNavigationController(rootViewController: pollViewController) + self.present(navigationViewController, animated: true, completion: nil) + + let activeAccount = NCDatabaseManager.sharedInstance().activeAccount() + NCAPIController.sharedInstance().getPoll(withId: pollId, inRoom: room.token, forAccount: activeAccount) { poll, error in + if let poll = poll, error == nil { + pollViewController.updatePoll(poll: poll) + } + } + } + + // MARK: - Other files + + func openLink(link: String) { + NCUtils.openLinkInBrowser(link: link) + } + + // MARK: - Message context + + private func contextMenuConfiguration(for message: NCChatMessage, identifier: NSCopying) -> UIContextMenuConfiguration? { + // The preview shows the context of the message, which is not available without the context endpoint + guard self.room.supportsMessageContext else { return nil } + + return UIContextMenuConfiguration(identifier: identifier, previewProvider: { + + // Init the BaseChatViewController without message to directly show a preview + if let account = self.room.account, let chatViewController = ContextChatViewController(forRoom: self.room, withAccount: account, withMessage: [], withHighlightId: 0) { + self.previewChatViewController = chatViewController + + // Fetch the context of the message and update the BaseChatViewController + chatViewController.showContext(ofMessageId: message.messageId, withLimit: 50, withCloseButton: false) + + let navController = NCNavigationController(rootViewController: chatViewController) + self.previewNavigationChatViewController = navController + + return navController + } + + return nil + }, actionProvider: { _ in + UIMenu(children: [UIAction(title: NSLocalizedString("Open", comment: "Context menu action to open a shared file")) { _ in + DispatchQueue.main.async { + self.presentPreviewChatViewController() + } + }]) + }) + } + + func presentPreviewChatViewController() { + guard let previewNavigationChatViewController = self.previewNavigationChatViewController, + let previewChatViewController = self.previewChatViewController + else { return } + + self.present(previewNavigationChatViewController, animated: false) + + previewChatViewController.navigationItem.rightBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Close", comment: ""), primaryAction: UIAction { [weak previewChatViewController] _ in + previewChatViewController?.dismiss(animated: true) + }) + } + + // MARK: - Table view + + func numberOfSections(in tableView: UITableView) -> Int { + // The media type is shown by the collection view + return self.isShowingMedia ? 0 : self.sections.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + guard section < self.sections.count else { return 0 } + + return self.sections[section].items.count + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + guard section < self.sections.count else { return nil } + + return self.sections[section].title + } + + private enum RowKind { + case audio, file, location, other + } + + private func rowKind(for message: NCChatMessage) -> RowKind { + switch currentItemType { + case kSharedItemTypeVoice, kSharedItemTypeAudio, kSharedItemTypeRecording: + // Video recordings are listed like files and open in the media viewer + return NCUtils.isAudio(fileType: message.file()?.mimetype ?? "") ? .audio : .file + case kSharedItemTypeFile: + return .file + case kSharedItemTypeLocation: + return .location + default: + return .other + } + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + guard let message = self.message(at: indexPath), self.rowKind(for: message) == .other else { + return UITableView.automaticDimension + } + + return DirectoryTableViewCell.cellHeight + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let message = self.message(at: indexPath) else { return UITableViewCell() } + + switch self.rowKind(for: message) { + case .audio: + let cell = tableView.dequeueReusableCell(withIdentifier: SharedAudioCell.identifier, for: indexPath) + + if let audioCell = cell as? SharedAudioCell { + audioCell.delegate = self + audioCell.configure(with: message) + self.updatePlayerView(of: audioCell) + } + + return cell + case .file: + let cell = tableView.dequeueReusableCell(withIdentifier: SharedFileCell.identifier, for: indexPath) + + if let fileCell = cell as? SharedFileCell, let account = self.room.account { + fileCell.configure(with: message, showsServerPreview: !self.room.isClassified, account: account) + } + + return cell + case .location: + let cell = tableView.dequeueReusableCell(withIdentifier: SharedLocationCell.identifier, for: indexPath) + (cell as? SharedLocationCell)?.configure(with: message) + + return cell + case .other: + break + } + + let cell = tableView.dequeueReusableCell(withIdentifier: DirectoryTableViewCell.identifier) as? DirectoryTableViewCell ?? + DirectoryTableViewCell(style: .default, reuseIdentifier: DirectoryTableViewCell.identifier) + + if let file = message.file() { + cell.fileNameLabel?.text = file.name + } else { + cell.fileNameLabel?.text = message.parsedMessage().string + } + + var infoLabelText = NCUtils.relativeTimeFromDate(date: Date(timeIntervalSince1970: Double(message.timestamp))) + if !message.actorDisplayName.isEmpty { + infoLabelText += " ⸱ " + message.actorDisplayName + } + if let file = message.file(), let size = file.size, size > 0 { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + let sizeString = formatter.string(fromByteCount: Int64(size)) + infoLabelText += " ⸱ " + sizeString + } + cell.fileInfoLabel?.text = infoLabelText + + let image = imageForMessage(message: message) + cell.fileImageView?.image = image + cell.fileImageView?.tintColor = .secondaryLabel + if message.file()?.previewAvailable != nil, let account = self.room.account { + cell.fileImageView?.setPreview(forFileId: message.file().parameterId, withWidth: 40, withHeight: 40, usingAccount: account) + } + return cell + } + + func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + guard let message = self.message(at: indexPath) else { return nil } + + return self.contextMenuConfiguration(for: message, identifier: indexPath as NSCopying) + } + + func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + animator.addAnimations { + self.presentPreviewChatViewController() + } + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let message = self.message(at: indexPath) else { return } + + self.tableView.deselectRow(at: indexPath, animated: true) + + switch self.rowKind(for: message) { + case .audio: + if self.audioPlayerMessageId == message.messageId, self.audioPlayer?.isPlaying == true { + self.pauseAudio() + } else { + self.playAudio(of: message) + } + case .file: + if NCMediaViewerViewController.canShowMedia(of: message) { + presentMedia(of: message) + } else if let file = message.file() { + downloadFile(file: file) + } + case .location: + if let geoLocation = message.geoLocation() { + presentLocation(location: GeoLocationRichObject(from: geoLocation)) + } + case .other: + self.openOtherItem(message) + } + } + + private func openOtherItem(_ message: NCChatMessage) { + switch currentItemType { + case kSharedItemTypeDeckcard, kSharedItemTypeOther: + if let link = message.objectShareLink() { + openLink(link: link) + } + case kSharedItemTypePoll: + if let poll = message.poll, let pollId = Int(poll.parameterId) { + presentPoll(pollId: pollId) + } + default: + return + } + } + + // MARK: - Collection view + + func numberOfSections(in collectionView: UICollectionView) -> Int { + return self.isShowingMedia ? self.sections.count : 0 + } + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + guard section < self.sections.count else { return 0 } + + return self.sections[section].items.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: SharedMediaCell.identifier, for: indexPath) + + guard let mediaCell = cell as? SharedMediaCell, + let message = self.message(at: indexPath), + let account = self.room.account + else { return cell } + + mediaCell.configure(with: message, aspectRatio: self.aspectRatio(of: message), showsServerPreview: !self.room.isClassified, account: account) + + return mediaCell + } + + func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { + let view = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: SharedMediaSectionHeaderView.identifier, for: indexPath) + + if let headerView = view as? SharedMediaSectionHeaderView, indexPath.section < self.sections.count { + headerView.setTitle(self.sections[indexPath.section].title ?? "") + } + + return view + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let message = self.message(at: indexPath) else { return } + + self.presentMedia(of: message) + } + + func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath], point: CGPoint) -> UIContextMenuConfiguration? { + guard indexPaths.count == 1, let indexPath = indexPaths.first, let message = self.message(at: indexPath) else { return nil } + + return self.contextMenuConfiguration(for: message, identifier: indexPath as NSCopying) + } + + func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + animator.addAnimations { + self.presentPreviewChatViewController() + } + } + + // MARK: - WaterfallLayoutDelegate + + func waterfallLayout(_ layout: WaterfallLayout, aspectRatioForItemAt indexPath: IndexPath) -> CGFloat { + guard let message = self.message(at: indexPath) else { return 1 } + + return self.aspectRatio(of: message) + } + + func waterfallLayout(_ layout: WaterfallLayout, heightForHeaderInSection section: Int) -> CGFloat { + return SharedMediaSectionHeaderView.height + } +} diff --git a/NextcloudTalk/Rooms/RoomTagsFilterView.swift b/NextcloudTalk/Rooms/RoomTagsFilterView.swift index 95f76b469..47e369753 100644 --- a/NextcloudTalk/Rooms/RoomTagsFilterView.swift +++ b/NextcloudTalk/Rooms/RoomTagsFilterView.swift @@ -44,6 +44,9 @@ class RoomTagsFilterView: UIView { public var onChipSelected: ((String) -> Void)? public var onChipLongPressed: (() -> Void)? + // Same background as InfoLabelTableViewCell (e.g. pending invitations row) + public var unselectedChipBackgroundColor: UIColor = .secondarySystemBackground + private let scrollView = FadingScrollView() private let stackView = UIStackView() private let feedbackGenerator = UISelectionFeedbackGenerator() @@ -91,7 +94,7 @@ class RoomTagsFilterView: UIView { stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } for chip in chips { - let chipControl = RoomTagChipControl(chip: chip, selected: chip.id == selectedChipId) + let chipControl = RoomTagChipControl(chip: chip, selected: chip.id == selectedChipId, unselectedBackgroundColor: unselectedChipBackgroundColor) chipControl.addTarget(self, action: #selector(chipTouchedDown(_:)), for: .touchDown) chipControl.addTarget(self, action: #selector(chipTapped(_:)), for: .touchUpInside) chipControl.addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(chipLongPressed(_:)))) @@ -101,10 +104,10 @@ class RoomTagsFilterView: UIView { } @objc private func chipLongPressed(_ recognizer: UILongPressGestureRecognizer) { - guard recognizer.state == .began else { return } + guard recognizer.state == .began, let onChipLongPressed else { return } feedbackGenerator.selectionChanged() - onChipLongPressed?() + onChipLongPressed() } @objc private func chipTouchedDown(_ sender: RoomTagChipControl) { @@ -128,13 +131,12 @@ private class RoomTagChipControl: UIControl { private let titleLabel = UILabel() private let contentStackView = UIStackView() - init(chip: TagFilterChip, selected: Bool) { + init(chip: TagFilterChip, selected: Bool, unselectedBackgroundColor: UIColor) { super.init(frame: .zero) self.chip = chip self.layer.masksToBounds = true - // Same background as InfoLabelTableViewCell (e.g. pending invitations row) - self.backgroundColor = selected ? NCAppBranding.themeColor() : .secondarySystemBackground + self.backgroundColor = selected ? NCAppBranding.themeColor() : unselectedBackgroundColor // Same font as the title in the conversation cells titleLabel.font = .preferredFont(forTextStyle: .headline) diff --git a/NextcloudTalk/Rooms/SharedItemCells.swift b/NextcloudTalk/Rooms/SharedItemCells.swift new file mode 100644 index 000000000..af7084c45 --- /dev/null +++ b/NextcloudTalk/Rooms/SharedItemCells.swift @@ -0,0 +1,461 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import UIKit +import MapKit +import SDWebImage + +class SharedItemTableViewCell: UITableViewCell { + + var fileParameter: NCMessageFileParameter? { + get { self.downloadObserver.fileParameter } + set { self.downloadObserver.fileParameter = newValue } + } + + private var activityIndicator: NCActivityIndicator? + + private lazy var downloadObserver = ChatFileDownloadObserver { [weak self] change in + self?.downloadDidChange(change) + } + + let titleLabel: UILabel = { + let label = UILabel() + label.font = .preferredFont(forTextStyle: .body) + label.adjustsFontForContentSizeCategory = true + label.textColor = .label + label.lineBreakMode = .byTruncatingMiddle + return label + }() + + let detailLabel: UILabel = { + let label = UILabel() + label.font = .preferredFont(forTextStyle: .subheadline) + label.adjustsFontForContentSizeCategory = true + label.textColor = .secondaryLabel + label.numberOfLines = 2 + return label + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + + self.fileParameter = nil + self.accessoryView = nil + self.activityIndicator = nil + } + + static func detailText(for message: NCChatMessage, includingActor: Bool) -> String { + var parts: [String] = [] + + if includingActor, let actorDisplayName = message.actorDisplayName, !actorDisplayName.isEmpty { + parts.append(actorDisplayName) + } + + parts.append(NCUtils.relativeTimeFromDate(date: Date(timeIntervalSince1970: Double(message.timestamp)))) + + return parts.joined(separator: " ⸱ ") + } + + private func downloadDidChange(_ change: ChatFileDownloadObserver.Change) { + switch change { + case .isDownloading(true) where self.activityIndicator == nil: + self.addActivityIndicator(with: 0) + case .isDownloading(false): + self.accessoryView = nil + self.activityIndicator = nil + case .progress(let progress): + if let activityIndicator = self.activityIndicator { + if let progress { + activityIndicator.indicatorMode = .determinate + activityIndicator.setProgress(progress, animated: true) + } + } else { + self.addActivityIndicator(with: progress ?? 0) + } + default: + break + } + } + + private func addActivityIndicator(with progress: Float) { + let indicator = NCActivityIndicator(frame: .init(x: 0, y: 0, width: 20, height: 20)) + self.activityIndicator = indicator + + indicator.radius = 7.0 + indicator.cycleColors = [.systemGray2] + + if progress > 0 { + indicator.indicatorMode = .determinate + indicator.setProgress(progress, animated: false) + } + + indicator.startAnimating() + self.accessoryView = indicator + } +} + +// MARK: - Files + +class SharedFileCell: SharedItemTableViewCell { + + static let identifier = "SharedFileCell" + + private static let previewSize: CGFloat = 56 + + private var previewRequest: SDWebImageCombinedOperation? + + private let previewImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.layer.cornerRadius = 8.0 + imageView.backgroundColor = .tertiarySystemFill + return imageView + }() + + private let fileIconView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + return imageView + }() + + private let sizeLabel: UILabel = { + let label = UILabel() + label.font = .preferredFont(forTextStyle: .subheadline) + label.adjustsFontForContentSizeCategory = true + label.textColor = .secondaryLabel + return label + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + let labelStack = UIStackView(arrangedSubviews: [self.titleLabel, self.detailLabel, self.sizeLabel]) + labelStack.axis = .vertical + labelStack.spacing = 2 + + self.contentView.addSubview(self.previewImageView) + self.previewImageView.addSubview(self.fileIconView) + self.contentView.addSubview(labelStack) + + for view in [self.previewImageView, self.fileIconView, labelStack] { + view.translatesAutoresizingMaskIntoConstraints = false + } + + NSLayoutConstraint.activate([ + self.previewImageView.leadingAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.leadingAnchor), + self.previewImageView.centerYAnchor.constraint(equalTo: self.contentView.centerYAnchor), + self.previewImageView.widthAnchor.constraint(equalToConstant: Self.previewSize), + self.previewImageView.heightAnchor.constraint(equalToConstant: Self.previewSize), + self.previewImageView.topAnchor.constraint(greaterThanOrEqualTo: self.contentView.topAnchor, constant: 10), + + self.fileIconView.centerXAnchor.constraint(equalTo: self.previewImageView.centerXAnchor), + self.fileIconView.centerYAnchor.constraint(equalTo: self.previewImageView.centerYAnchor), + self.fileIconView.widthAnchor.constraint(equalToConstant: 36), + self.fileIconView.heightAnchor.constraint(equalToConstant: 36), + + labelStack.leadingAnchor.constraint(equalTo: self.previewImageView.trailingAnchor, constant: 14), + labelStack.trailingAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.trailingAnchor), + labelStack.centerYAnchor.constraint(equalTo: self.contentView.centerYAnchor), + labelStack.topAnchor.constraint(greaterThanOrEqualTo: self.contentView.topAnchor, constant: 10) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + + self.previewRequest?.cancel() + self.previewRequest = nil + self.previewImageView.image = nil + self.fileIconView.image = nil + } + + func configure(with message: NCChatMessage, showsServerPreview: Bool, account: TalkAccount) { + guard let file = message.file() else { return } + + self.fileParameter = file + self.titleLabel.text = file.name + self.detailLabel.text = Self.detailText(for: message, includingActor: true) + + if let size = file.size, size > 0 { + self.sizeLabel.text = ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file) + self.sizeLabel.isHidden = false + } else { + self.sizeLabel.isHidden = true + } + + self.fileIconView.image = UIImage(named: NCUtils.previewImage(forMimeType: file.mimetype)) + + guard file.previewAvailable, showsServerPreview else { return } + + let fileId = file.parameterId + let pixelSize = Int(Self.previewSize * 3) + + self.previewRequest = NCAPIController.sharedInstance().getPreviewForFile(fileId, width: pixelSize, height: pixelSize, forAccount: account) { [weak self] image, _ in + guard let self, let image, self.fileParameter?.parameterId == fileId else { return } + + self.previewImageView.image = image + self.fileIconView.image = nil + } + } +} + +// MARK: - Voice messages and audio + +protocol SharedAudioCellDelegate: AnyObject { + func sharedAudioCellWantsToPlay(_ cell: SharedAudioCell) + func sharedAudioCellWantsToPause(_ cell: SharedAudioCell) + func sharedAudioCell(_ cell: SharedAudioCell, wantsToSeekTo time: TimeInterval) +} + +class SharedAudioCell: SharedItemTableViewCell, AudioPlayerViewDelegate { + + static let identifier = "SharedAudioCell" + + weak var delegate: SharedAudioCellDelegate? + private(set) var messageId: Int? + + let playerView = AudioPlayerView(frame: .init(x: 0, y: 0, width: 300, height: 52)) + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + self.selectionStyle = .none + self.playerView.delegate = self + self.playerView.resetPlayer() + + let labelStack = UIStackView(arrangedSubviews: [self.titleLabel, self.detailLabel]) + labelStack.axis = .vertical + labelStack.spacing = 2 + + self.contentView.addSubview(labelStack) + self.contentView.addSubview(self.playerView) + + labelStack.translatesAutoresizingMaskIntoConstraints = false + self.playerView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + labelStack.topAnchor.constraint(equalTo: self.contentView.topAnchor, constant: 12), + labelStack.leadingAnchor.constraint(equalTo: self.playerView.leadingAnchor), + labelStack.trailingAnchor.constraint(equalTo: self.playerView.trailingAnchor), + + self.playerView.topAnchor.constraint(equalTo: labelStack.bottomAnchor, constant: 8), + self.playerView.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor, constant: 12), + self.playerView.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor, constant: -12), + self.playerView.heightAnchor.constraint(equalToConstant: 52), + self.playerView.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor, constant: -12) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + + self.messageId = nil + self.playerView.resetPlayer() + } + + func configure(with message: NCChatMessage) { + let file = message.file() + let actorDisplayName = message.actorDisplayName ?? "" + + self.messageId = message.messageId + self.fileParameter = file + + if message.isVoiceMessage || file?.name.isEmpty != false { + self.titleLabel.text = actorDisplayName + self.detailLabel.text = Self.detailText(for: message, includingActor: false) + } else { + self.titleLabel.text = file?.name + self.detailLabel.text = Self.detailText(for: message, includingActor: true) + } + } + + func audioPlayerPlayButtonPressed() { + self.delegate?.sharedAudioCellWantsToPlay(self) + } + + func audioPlayerPauseButtonPressed() { + self.delegate?.sharedAudioCellWantsToPause(self) + } + + func audioPlayerProgressChanged(progress: CGFloat) { + self.delegate?.sharedAudioCell(self, wantsToSeekTo: TimeInterval(progress)) + } +} + +// MARK: - Locations + +class SharedLocationCell: SharedItemTableViewCell { + + static let identifier = "SharedLocationCell" + + private static let mapHeight: CGFloat = 140 + + /// Rendering a snapshot takes a moment, so scrolling back shouldn't redo it + private static let snapshotCache = NSCache() + + private var coordinate: CLLocationCoordinate2D? + private var snapshotKey: String? + private var snapshotter: MKMapSnapshotter? + + private let mapImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.layer.cornerRadius = 8.0 + imageView.backgroundColor = .tertiarySystemFill + return imageView + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + self.titleLabel.lineBreakMode = .byTruncatingTail + self.titleLabel.numberOfLines = 2 + + let labelStack = UIStackView(arrangedSubviews: [self.titleLabel, self.detailLabel]) + labelStack.axis = .vertical + labelStack.spacing = 2 + + self.contentView.addSubview(self.mapImageView) + self.contentView.addSubview(labelStack) + + self.mapImageView.translatesAutoresizingMaskIntoConstraints = false + labelStack.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + self.mapImageView.topAnchor.constraint(equalTo: self.contentView.topAnchor, constant: 12), + self.mapImageView.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor, constant: 12), + self.mapImageView.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor, constant: -12), + self.mapImageView.heightAnchor.constraint(equalToConstant: Self.mapHeight), + + labelStack.topAnchor.constraint(equalTo: self.mapImageView.bottomAnchor, constant: 10), + labelStack.leadingAnchor.constraint(equalTo: self.mapImageView.leadingAnchor), + labelStack.trailingAnchor.constraint(equalTo: self.mapImageView.trailingAnchor), + labelStack.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor, constant: -12) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + + self.snapshotter?.cancel() + self.snapshotter = nil + self.snapshotKey = nil + self.coordinate = nil + self.mapImageView.image = nil + } + + func configure(with message: NCChatMessage) { + self.detailLabel.text = Self.detailText(for: message, includingActor: true) + + guard let geoLocationParameter = message.geoLocation() else { return } + + let geoLocation = GeoLocationRichObject(from: geoLocationParameter) + self.titleLabel.text = geoLocation.name + + if let latitude = Double(geoLocation.latitude), let longitude = Double(geoLocation.longitude) { + self.coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } + + self.setNeedsLayout() + } + + override func layoutSubviews() { + super.layoutSubviews() + + // The snapshot needs the final width, which is only known here + self.updateSnapshotIfNeeded() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + if self.traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) || + self.traitCollection.displayScale != previousTraitCollection?.displayScale { + self.updateSnapshotIfNeeded() + } + } + + private func updateSnapshotIfNeeded() { + guard let coordinate = self.coordinate else { return } + + let size = self.mapImageView.bounds.size + let scale = max(self.traitCollection.displayScale, 1) + let style = self.traitCollection.userInterfaceStyle + + guard size.width > 0 else { return } + + let key = "\(coordinate.latitude),\(coordinate.longitude),\(Int(size.width))x\(Int(size.height))@\(scale),\(style.rawValue)" + + guard key != self.snapshotKey else { return } + + self.snapshotKey = key + self.snapshotter?.cancel() + + if let cached = Self.snapshotCache.object(forKey: key as NSString) { + self.mapImageView.image = cached + return + } + + let options = MKMapSnapshotter.Options() + options.region = MKCoordinateRegion(center: coordinate, span: .init(latitudeDelta: 0.005, longitudeDelta: 0.005)) + options.size = size + options.scale = scale + options.traitCollection = UITraitCollection(userInterfaceStyle: style) + + let snapshotter = MKMapSnapshotter(options: options) + self.snapshotter = snapshotter + + snapshotter.start { [weak self] snapshot, _ in + guard let snapshot else { return } + + let image = Self.image(of: snapshot, withPinAt: coordinate) + Self.snapshotCache.setObject(image, forKey: key as NSString) + + guard let self, self.snapshotKey == key else { return } + + self.mapImageView.image = image + } + } + + private static func image(of snapshot: MKMapSnapshotter.Snapshot, withPinAt coordinate: CLLocationCoordinate2D) -> UIImage { + let pin = MKPinAnnotationView(annotation: nil, reuseIdentifier: nil) + pin.pinTintColor = NCAppBranding.elementColor() + + let format = UIGraphicsImageRendererFormat() + format.scale = snapshot.image.scale + + return UIGraphicsImageRenderer(size: snapshot.image.size, format: format).image { _ in + snapshot.image.draw(at: .zero) + + var point = snapshot.point(for: coordinate) + point.x += pin.centerOffset.x - (pin.bounds.width / 2) + point.y += pin.centerOffset.y - (pin.bounds.height / 2) + + pin.image?.draw(at: point) + } + } +} diff --git a/NextcloudTalk/Rooms/SharedMediaCell.swift b/NextcloudTalk/Rooms/SharedMediaCell.swift new file mode 100644 index 000000000..01ccb97f7 --- /dev/null +++ b/NextcloudTalk/Rooms/SharedMediaCell.swift @@ -0,0 +1,282 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import UIKit +import SDWebImage + +class SharedMediaCell: UICollectionViewCell { + + static let identifier = "SharedMediaCell" + + /// Decoding a blurhash generates a bitmap synchronously on the main thread, once per file is enough + private static let blurhashPlaceholderCache = NSCache() + + private var fileId: String? + private var previewRequest: SDWebImageCombinedOperation? + private var isVideo = false + + private lazy var downloadObserver = ChatFileDownloadObserver { [weak self] change in + self?.downloadDidChange(change) + } + + private let downloadIndicator: MediaDownloadIndicatorView = { + let downloadIndicator = MediaDownloadIndicatorView(frame: .zero) + downloadIndicator.isHidden = true + return downloadIndicator + }() + + private let imageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + return imageView + }() + + private let fileIconView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.isHidden = true + return imageView + }() + + private let playIconView: 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.contentMode = .scaleAspectFit + imageView.isHidden = true + return imageView + }() + + private let captionView: UIView = { + let view = UIView() + view.backgroundColor = UIColor.black.withAlphaComponent(0.55) + return view + }() + + private let actorLabel: UILabel = { + let label = UILabel() + label.font = UIFont.preferredFont(for: .caption1, weight: .semibold) + label.adjustsFontForContentSizeCategory = true + label.textColor = .white + label.lineBreakMode = .byTruncatingTail + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + + self.contentView.backgroundColor = .secondarySystemGroupedBackground + self.contentView.layer.cornerRadius = 8.0 + self.contentView.clipsToBounds = true + self.updateBorder() + + self.isAccessibilityElement = true + self.accessibilityTraits = .button + + self.contentView.addSubview(self.imageView) + self.contentView.addSubview(self.fileIconView) + self.contentView.addSubview(self.playIconView) + self.contentView.addSubview(self.captionView) + self.contentView.addSubview(self.downloadIndicator) + self.captionView.addSubview(self.actorLabel) + + for view in [self.imageView, self.fileIconView, self.playIconView, self.captionView, self.actorLabel] { + view.translatesAutoresizingMaskIntoConstraints = false + } + + NSLayoutConstraint.activate([ + self.imageView.topAnchor.constraint(equalTo: self.contentView.topAnchor), + self.imageView.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor), + self.imageView.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor), + self.imageView.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor), + + self.fileIconView.centerXAnchor.constraint(equalTo: self.contentView.centerXAnchor), + self.fileIconView.centerYAnchor.constraint(equalTo: self.contentView.centerYAnchor), + self.fileIconView.widthAnchor.constraint(equalToConstant: 48), + self.fileIconView.heightAnchor.constraint(equalToConstant: 48), + + self.playIconView.centerXAnchor.constraint(equalTo: self.contentView.centerXAnchor), + self.playIconView.centerYAnchor.constraint(equalTo: self.contentView.centerYAnchor), + self.playIconView.widthAnchor.constraint(equalToConstant: 44), + self.playIconView.heightAnchor.constraint(equalToConstant: 44), + + self.downloadIndicator.centerXAnchor.constraint(equalTo: self.contentView.centerXAnchor), + self.downloadIndicator.centerYAnchor.constraint(equalTo: self.contentView.centerYAnchor), + + self.captionView.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor), + self.captionView.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor), + self.captionView.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor), + + self.actorLabel.topAnchor.constraint(equalTo: self.captionView.topAnchor, constant: 4), + self.actorLabel.bottomAnchor.constraint(equalTo: self.captionView.bottomAnchor, constant: -4), + self.actorLabel.leadingAnchor.constraint(equalTo: self.captionView.leadingAnchor, constant: 8), + self.actorLabel.trailingAnchor.constraint(equalTo: self.captionView.trailingAnchor, constant: -8) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + if self.traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) || + self.traitCollection.displayScale != previousTraitCollection?.displayScale { + self.updateBorder() + } + } + + private func updateBorder() { + // A CGColor doesn't follow dark mode by itself + self.contentView.layer.borderColor = UIColor.label.withAlphaComponent(0.18).resolvedColor(with: self.traitCollection).cgColor + self.contentView.layer.borderWidth = 1 / max(self.traitCollection.displayScale, 1) + } + + override func prepareForReuse() { + super.prepareForReuse() + + self.previewRequest?.cancel() + self.previewRequest = nil + self.fileId = nil + self.imageView.image = nil + self.fileIconView.isHidden = true + self.downloadObserver.fileParameter = nil + self.showsDownload(false) + } + + func configure(with message: NCChatMessage, aspectRatio: CGFloat, showsServerPreview: Bool, account: TalkAccount) { + let file = message.file() + let actorDisplayName = message.actorDisplayName ?? "" + + self.fileId = file?.parameterId + self.actorLabel.text = actorDisplayName + self.captionView.isHidden = actorDisplayName.isEmpty + self.isVideo = NCUtils.isVideo(fileType: file?.mimetype ?? "") + self.downloadObserver.fileParameter = file + // A download started before this cell showed the file only reports its next change + self.showsDownload(file.flatMap { ChatFileDownloader.shared.downloadStatus(forFileId: $0.parameterId)?.isDownloading } ?? false) + self.accessibilityLabel = [file?.name ?? "", actorDisplayName].filter { !$0.isEmpty }.joined(separator: ", ") + + guard let file, file.previewAvailable else { + self.showFileIcon(for: file) + return + } + + self.imageView.image = Self.blurhashPlaceholder(for: file, aspectRatio: aspectRatio) + + guard showsServerPreview else { + if self.imageView.image == nil { + self.showFileIcon(for: file) + } + + return + } + + // Same as the chat cells, so the media viewer finds it cached, the server rounds it up to 1024 px anyway + let previewHeight = Int(3 * fileMessageCellFileMaxPreviewHeight) + let fileId = file.parameterId + + self.previewRequest = NCAPIController.sharedInstance().getPreviewForFile(fileId, width: -1, height: previewHeight, forAccount: account) { [weak self] image, _ in + guard let self, self.fileId == fileId else { return } + + if let image { + self.imageView.image = image + } else { + self.showFileIcon(for: file) + } + } + } + + private func downloadDidChange(_ change: ChatFileDownloadObserver.Change) { + switch change { + case .isDownloading(let isDownloading): + self.showsDownload(isDownloading) + case .progress(let progress): + self.showsDownload(true) + + if let progress { + self.downloadIndicator.setProgress(progress) + } + } + } + + private func showsDownload(_ showsDownload: Bool) { + if showsDownload, self.downloadIndicator.isHidden { + self.downloadIndicator.showProgress() + } else if !showsDownload { + self.downloadIndicator.stopAnimating() + } + + self.downloadIndicator.isHidden = !showsDownload + self.playIconView.isHidden = showsDownload || !self.isVideo + } + + private func showFileIcon(for file: NCMessageFileParameter?) { + self.imageView.image = nil + self.fileIconView.image = UIImage(named: NCUtils.previewImage(forMimeType: file?.mimetype)) + self.fileIconView.isHidden = false + } + + private static func blurhashPlaceholder(for file: NCMessageFileParameter, aspectRatio: CGFloat) -> UIImage? { + guard let blurhash = file.blurhash, aspectRatio > 0 else { return nil } + + let placeholderSize = CGSize(width: 20, height: (20 / aspectRatio).rounded()) + let cacheKey = "\(blurhash)-\(Int(placeholderSize.height))" as NSString + + if let cached = self.blurhashPlaceholderCache.object(forKey: cacheKey) { + return cached + } + + guard let decoded = UIImage(blurHash: blurhash, size: placeholderSize) else { return nil } + + self.blurhashPlaceholderCache.setObject(decoded, forKey: cacheKey) + + return decoded + } +} + +class SharedMediaSectionHeaderView: UICollectionReusableView { + + static let identifier = "SharedMediaSectionHeaderView" + + private static var titleFont: UIFont { + return UIFont.preferredFont(for: .title3, weight: .bold) + } + + /// Follows the text size, as the layout needs it before any header exists + static var height: CGFloat { + return (self.titleFont.lineHeight + 16).rounded(.up) + } + + private let titleLabel: UILabel = { + let label = UILabel() + label.textColor = .label + label.accessibilityTraits = .header + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + + self.addSubview(self.titleLabel) + self.titleLabel.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + self.titleLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 8), + self.titleLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8), + self.titleLabel.centerYAnchor.constraint(equalTo: self.centerYAnchor) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setTitle(_ title: String) { + self.titleLabel.font = Self.titleFont + self.titleLabel.text = title + } +} diff --git a/NextcloudTalk/User Interface/ScrollViewFade.swift b/NextcloudTalk/User Interface/ScrollViewFade.swift index b14b6d02f..2f8324b3b 100644 --- a/NextcloudTalk/User Interface/ScrollViewFade.swift +++ b/NextcloudTalk/User Interface/ScrollViewFade.swift @@ -21,10 +21,8 @@ extension UIScrollView { let canScrollToLeading = self.contentOffset.x > minOffset + 1 let canScrollToTrailing = self.contentOffset.x < maxOffset - 1 - guard canScrollToLeading || canScrollToTrailing else { - self.layer.mask = nil - return - } + // Without anything to fade there's no need for a mask yet + guard canScrollToLeading || canScrollToTrailing || self.layer.mask != nil else { return } let fadeLayer = self.layer.mask as? CAGradientLayer ?? { let gradientLayer = CAGradientLayer() @@ -41,12 +39,25 @@ extension UIScrollView { // Scroll views scroll by moving their bounds origin, so using the bounds here keeps the // mask in place while the content moves underneath it + let colors = [canScrollToLeading ? clear : opaque, opaque, opaque, canScrollToTrailing ? clear : opaque] + let previousColors = fadeLayer.colors as? [CGColor] + CATransaction.begin() CATransaction.setDisableActions(true) fadeLayer.frame = self.bounds - fadeLayer.colors = [canScrollToLeading ? clear : opaque, opaque, opaque, canScrollToTrailing ? clear : opaque] + fadeLayer.colors = colors fadeLayer.locations = [0, NSNumber(value: fade), NSNumber(value: 1 - fade), 1] CATransaction.commit() + + // An edge reaching or leaving its end fades, instead of the fade popping in and out + if let previousColors, previousColors != colors { + let animation = CABasicAnimation(keyPath: "colors") + animation.fromValue = fadeLayer.presentation()?.colors ?? previousColors + animation.toValue = colors + animation.duration = 0.2 + animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + fadeLayer.add(animation, forKey: "colors") + } } /// Scrolls `view` into the visible area, keeping it clear of the fade at the edges diff --git a/NextcloudTalk/User Interface/WaterfallLayout.swift b/NextcloudTalk/User Interface/WaterfallLayout.swift new file mode 100644 index 000000000..e280f3f2f --- /dev/null +++ b/NextcloudTalk/User Interface/WaterfallLayout.swift @@ -0,0 +1,175 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import UIKit + +protocol WaterfallLayoutDelegate: AnyObject { + /// The aspect ratio (width / height) of the item at `indexPath`. Must not change once the item has + /// been placed, as the layout relies on that to never move already placed items. + func waterfallLayout(_ layout: WaterfallLayout, aspectRatioForItemAt indexPath: IndexPath) -> CGFloat + + /// The height of the full-width header above `section`, or 0 for none. + func waterfallLayout(_ layout: WaterfallLayout, heightForHeaderInSection section: Int) -> CGFloat +} + +extension WaterfallLayoutDelegate { + func waterfallLayout(_ layout: WaterfallLayout, heightForHeaderInSection section: Int) -> CGFloat { + return 0 + } +} + +/// A masonry ("waterfall") layout: items share a column width but keep their own aspect ratio, each +/// placed into the currently shortest column. Unlike a flow layout with fixed item sizes, this shows +/// images without letterboxing or cropping them. Each section starts new columns below the previous one. +class WaterfallLayout: UICollectionViewLayout { + + weak var delegate: WaterfallLayoutDelegate? + + var interItemSpacing: CGFloat = 8 + var lineSpacing: CGFloat = 8 + var sectionSpacing: CGFloat = 16 + var sectionInset = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8) + + /// The column count is derived from the available width to stay close to this, which keeps items + /// a sensible size on a phone, an iPad sheet and a full-screen iPad window alike. + var preferredColumnWidth: CGFloat = 165 + + /// The preferred width alone would give a single full-width column on the narrowest phones + var minimumColumnCount = 2 + + /// Clamped, so that one very tall or very wide item cannot unbalance a whole column + private static let minAspectRatio: CGFloat = 0.55 + private static let maxAspectRatio: CGFloat = 2.2 + + private static func clampedAspectRatio(_ aspectRatio: CGFloat) -> CGFloat { + return min(max(aspectRatio, Self.minAspectRatio), Self.maxAspectRatio) + } + + private var itemAttributesCache: [[UICollectionViewLayoutAttributes]] = [] + private var headerAttributesCache: [Int: UICollectionViewLayoutAttributes] = [:] + private var contentHeight: CGFloat = 0 + + private var contentWidth: CGFloat { + return self.collectionView?.bounds.width ?? 0 + } + + /// The section inset, widened by the horizontal safe area (e.g. the notch in landscape). + /// + /// Insetting the items rather than narrowing the content avoids making the content width depend + /// on the adjusted content inset, which in turn depends on the content size computed from it. + private var horizontalInsets: (left: CGFloat, right: CGFloat) { + let safeAreaInsets = self.collectionView?.safeAreaInsets ?? .zero + + return (self.sectionInset.left + safeAreaInsets.left, self.sectionInset.right + safeAreaInsets.right) + } + + override var collectionViewContentSize: CGSize { + return CGSize(width: self.contentWidth, height: self.contentHeight) + } + + override func prepare() { + super.prepare() + + self.itemAttributesCache.removeAll(keepingCapacity: true) + self.headerAttributesCache.removeAll(keepingCapacity: true) + self.contentHeight = 0 + + guard let collectionView = self.collectionView else { return } + + let sectionCount = collectionView.numberOfSections + let insets = self.horizontalInsets + let availableWidth = self.contentWidth - insets.left - insets.right + + guard sectionCount > 0, availableWidth > 0 else { return } + + // Recomputed in full on every invalidation: placement is deterministic, so existing items + // keep identical frames and no cached state can go stale (a new search can return the same + // number of items with entirely different sizes). + let columnCount = max(self.minimumColumnCount, Int((availableWidth + self.interItemSpacing) / (self.preferredColumnWidth + self.interItemSpacing))) + let itemWidth = ((availableWidth - self.interItemSpacing * CGFloat(columnCount - 1)) / CGFloat(columnCount)).rounded(.down) + + var sectionTop = self.sectionInset.top + var contentBottom: CGFloat? + + for section in 0.. 0 { + let attributes = UICollectionViewLayoutAttributes(forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + with: IndexPath(item: 0, section: section)) + attributes.frame = CGRect(x: insets.left, y: sectionTop, width: availableWidth, height: headerHeight) + self.headerAttributesCache[section] = attributes + + sectionTop += headerHeight + } + + var columnBottoms = [CGFloat](repeating: sectionTop, count: columnCount) + + for item in 0.. 0 || headerHeight > 0 else { continue } + + // The trailing line spacing of the tallest column is not part of the content + let sectionBottom = itemCount > 0 ? (columnBottoms.max() ?? sectionTop) - self.lineSpacing : sectionTop + + contentBottom = sectionBottom + sectionTop = sectionBottom + self.sectionSpacing + } + + if let contentBottom { + self.contentHeight = contentBottom + self.sectionInset.bottom + } + } + + override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { + let items = self.itemAttributesCache.joined().filter { $0.frame.intersects(rect) } + let headers = self.headerAttributesCache.values.filter { $0.frame.intersects(rect) } + + return items + headers + } + + override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { + guard indexPath.section < self.itemAttributesCache.count, + indexPath.item < self.itemAttributesCache[indexPath.section].count + else { return nil } + + return self.itemAttributesCache[indexPath.section][indexPath.item] + } + + override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { + guard elementKind == UICollectionView.elementKindSectionHeader else { return nil } + + return self.headerAttributesCache[indexPath.section] + } + + override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { + guard let collectionView = self.collectionView else { return false } + + // Only the width matters (column count and item width); scrolling must not invalidate + return newBounds.width != collectionView.bounds.width + } +}