diff --git a/apple/InlineIOS/Features/Chat/MessageCell.swift b/apple/InlineIOS/Features/Chat/MessageCell.swift index 4151defa..8898d55c 100644 --- a/apple/InlineIOS/Features/Chat/MessageCell.swift +++ b/apple/InlineIOS/Features/Chat/MessageCell.swift @@ -17,6 +17,7 @@ class MessageCollectionViewCell: UICollectionViewCell, UIGestureRecognizerDelega weak var delegate: MessageCellDelegate? var onUserTap: ((Int64) -> Void)? + var onPhotoTap: ((FullMessage, UIView, UIImage?, URL) -> Void)? private var panGesture: UIPanGestureRecognizer! private var swipeActive = false private var initialTranslation: CGFloat = 0 @@ -119,6 +120,7 @@ class MessageCollectionViewCell: UICollectionViewCell, UIGestureRecognizerDelega // Reset delegate delegate = nil + onPhotoTap = nil } // MARK: - Constraints @@ -411,6 +413,9 @@ extension MessageCollectionViewCell { func setupBaseMessageConstraints() { let newMessageView = UIMessageView(fullMessage: message, spaceId: spaceId) newMessageView.translatesAutoresizingMaskIntoConstraints = false + newMessageView.onPhotoTap = { [weak self] message, sourceView, sourceImage, url in + self?.onPhotoTap?(message, sourceView, sourceImage, url) + } contentView.addSubview(newMessageView) let topConstraint: NSLayoutConstraint diff --git a/apple/InlineIOS/Features/Chat/MessagesCollectionView.swift b/apple/InlineIOS/Features/Chat/MessagesCollectionView.swift index 473d02ef..5cf1829a 100644 --- a/apple/InlineIOS/Features/Chat/MessagesCollectionView.swift +++ b/apple/InlineIOS/Features/Chat/MessagesCollectionView.swift @@ -199,6 +199,16 @@ final class MessagesCollectionView: UICollectionView { return true } + func sourceViewForMessageId(_ messageId: Int64) -> UIView? { + guard let indexPath = findIndexPath(for: messageId), + let cell = cellForItem(at: indexPath) as? MessageCollectionViewCell + else { + return nil + } + + return cell.messageView?.newPhotoView.imageView + } + private func safeScrollToTop(animated: Bool = true) { // Check both view model and data source to avoid race conditions guard coordinator.viewModel.numberOfSections() > 0, @@ -496,6 +506,7 @@ private extension MessagesCollectionView { private weak var collectionContextMenu: UIContextMenuInteraction? private var cancellables = Set() private var updateWorkItem: DispatchWorkItem? + private var isPresentingImageViewer = false // MARK: - Date Separator Visibility Handling @@ -642,6 +653,15 @@ private extension MessagesCollectionView { userInfo: ["userId": userId] ) } + + cell.onPhotoTap = { [weak self] message, sourceView, sourceImage, url in + self?.presentPhotoGallery( + for: message, + sourceView: sourceView, + sourceImage: sourceImage, + imageURL: url + ) + } } dataSource = UICollectionViewDiffableDataSource( @@ -851,6 +871,88 @@ private extension MessagesCollectionView { UnreadManager.shared.readAll(peerId, chatId: chatId) } + private func presentPhotoGallery( + for message: FullMessage, + sourceView: UIView, + sourceImage: UIImage?, + imageURL: URL + ) { + guard message.message.isSticker != true else { return } + guard !isPresentingImageViewer else { return } + guard let collectionView = currentCollectionView as? MessagesCollectionView else { return } + guard let viewController = collectionView.findViewController() else { return } + if viewController.presentedViewController != nil || + viewController.isBeingPresented || + viewController.isBeingDismissed + { + return + } + + var items = buildImageItems() + let messageId = message.message.messageId + if !items.contains(where: { $0.id == messageId }) { + items.append(ImageViewerItem(id: messageId, url: imageURL)) + } + let initialIndex = items.firstIndex(where: { $0.id == messageId }) ?? 0 + + let viewer = ImageViewerController( + imageItems: items, + initialIndex: initialIndex, + sourceView: sourceView, + sourceImage: sourceImage, + sourceViewProvider: { [weak collectionView] id in + collectionView?.sourceViewForMessageId(id) + } + ) + viewer.onDismiss = { [weak self] in + self?.isPresentingImageViewer = false + } + + isPresentingImageViewer = true + viewController.present(viewer, animated: false) + } + + private func buildImageItems() -> [ImageViewerItem] { + let photoMessages = viewModel.sections + .flatMap(\.messages) + .filter { $0.photoInfo != nil && $0.message.isSticker != true } + + let sortedMessages = photoMessages.sorted { left, right in + if left.message.date != right.message.date { + return left.message.date < right.message.date + } + return left.message.messageId < right.message.messageId + } + + var items: [ImageViewerItem] = [] + items.reserveCapacity(sortedMessages.count) + + for message in sortedMessages { + guard let url = photoURL(for: message) else { continue } + items.append(ImageViewerItem(id: message.message.messageId, url: url)) + } + + return items + } + + private func photoURL(for message: FullMessage) -> URL? { + guard let photoInfo = message.photoInfo, + let photoSize = photoInfo.bestPhotoSize() + else { + return nil + } + + if let localPath = photoSize.localPath { + return FileCache.getUrl(for: .photos, localPath: localPath) + } + + if let cdnUrl = photoSize.cdnUrl { + return URL(string: cdnUrl) + } + + return nil + } + private var sizeCache: [FullMessage.ID: CGSize] = [:] private let maxCacheSize = 1_000 diff --git a/apple/InlineIOS/Features/Media/ImageViewerController.swift b/apple/InlineIOS/Features/Media/ImageViewerController.swift index f4dff900..34d9ec53 100644 --- a/apple/InlineIOS/Features/Media/ImageViewerController.swift +++ b/apple/InlineIOS/Features/Media/ImageViewerController.swift @@ -5,15 +5,23 @@ import Nuke import NukeUI import UIKit +struct ImageViewerItem: Hashable { + let id: Int64 + let url: URL +} + final class ImageViewerController: UIViewController { // MARK: - Properties - private let imageURL: URL? + private var imageURL: URL? private let videoURL: URL? private weak var sourceView: UIView? private let sourceImage: UIImage? - private let sourceFrame: CGRect + private var sourceFrame: CGRect private let showInChatAction: (() -> Void)? + private let imageItems: [ImageViewerItem] + private var currentIndex: Int + private let sourceViewProvider: ((Int64) -> UIView?)? var onDismiss: (() -> Void)? private let isVideo: Bool @@ -21,6 +29,8 @@ final class ImageViewerController: UIViewController { private var audioSessionSnapshot: AudioSessionSnapshot? private var playerViewController: AVPlayerViewController? private var didRestoreAudioSession = false + private var didRegisterImageObserver = false + private var didApplySourceImage = false private struct AudioSessionSnapshot { let category: AVAudioSession.Category @@ -121,6 +131,23 @@ final class ImageViewerController: UIViewController { return button }() + private lazy var pageIndicatorView: UIView = { + let view = UIView() + view.backgroundColor = UIColor.black.withAlphaComponent(0.5) + view.layer.cornerRadius = 12 + view.translatesAutoresizingMaskIntoConstraints = false + view.isHidden = imageItems.count <= 1 + return view + }() + + private lazy var pageIndicatorLabel: UILabel = { + let label = UILabel() + label.textColor = .white + label.font = .systemFont(ofSize: 13, weight: .semibold) + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + // MARK: - Initialization @@ -132,12 +159,47 @@ final class ImageViewerController: UIViewController { self.showInChatAction = showInChatAction self.isVideo = false self.sourceFrame = sourceView.convert(sourceView.bounds, to: nil) + self.imageItems = [] + self.currentIndex = 0 + self.sourceViewProvider = nil super.init(nibName: nil, bundle: nil) modalPresentationStyle = .overFullScreen modalTransitionStyle = .crossDissolve } + init( + imageItems: [ImageViewerItem], + initialIndex: Int, + sourceView: UIView, + sourceImage: UIImage? = nil, + showInChatAction: (() -> Void)? = nil, + sourceViewProvider: ((Int64) -> UIView?)? = nil + ) { + self.imageItems = imageItems + self.sourceViewProvider = sourceViewProvider + + if imageItems.isEmpty { + self.imageURL = nil + self.currentIndex = 0 + } else { + let safeIndex = max(0, min(initialIndex, imageItems.count - 1)) + self.imageURL = imageItems[safeIndex].url + self.currentIndex = safeIndex + } + + self.videoURL = nil + self.sourceView = sourceView + self.sourceImage = sourceImage + self.showInChatAction = showInChatAction + self.isVideo = false + self.sourceFrame = sourceView.convert(sourceView.bounds, to: nil) + + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + modalTransitionStyle = .crossDissolve + } + init(videoURL: URL, sourceView: UIView, sourceImage: UIImage? = nil, showInChatAction: (() -> Void)? = nil) { self.imageURL = nil self.videoURL = videoURL @@ -146,6 +208,9 @@ final class ImageViewerController: UIViewController { self.showInChatAction = showInChatAction self.isVideo = true self.sourceFrame = sourceView.convert(sourceView.bounds, to: nil) + self.imageItems = [] + self.currentIndex = 0 + self.sourceViewProvider = nil super.init(nibName: nil, bundle: nil) modalPresentationStyle = .overFullScreen @@ -163,6 +228,7 @@ final class ImageViewerController: UIViewController { super.viewDidLoad() setupViews() setupGestures() + updatePageIndicator() if let sourceImage { imageView.imageView.image = sourceImage @@ -208,6 +274,11 @@ final class ImageViewerController: UIViewController { controlsContainerView.addSubview(closeButton) controlsContainerView.addSubview(shareButton) controlsContainerView.addSubview(showInChatButton) + + if imageItems.count > 1 { + controlsContainerView.addSubview(pageIndicatorView) + pageIndicatorView.addSubview(pageIndicatorLabel) + } NSLayoutConstraint.activate([ scrollView.topAnchor.constraint(equalTo: view.topAnchor), @@ -246,6 +317,18 @@ final class ImageViewerController: UIViewController { showInChatButton.leadingAnchor.constraint(greaterThanOrEqualTo: closeButton.trailingAnchor, constant: 8), showInChatButton.trailingAnchor.constraint(lessThanOrEqualTo: shareButton.leadingAnchor, constant: -8) ]) + + if imageItems.count > 1 { + NSLayoutConstraint.activate([ + pageIndicatorView.centerXAnchor.constraint(equalTo: controlsContainerView.centerXAnchor), + pageIndicatorView.centerYAnchor.constraint(equalTo: closeButton.centerYAnchor), + + pageIndicatorLabel.topAnchor.constraint(equalTo: pageIndicatorView.topAnchor, constant: 6), + pageIndicatorLabel.bottomAnchor.constraint(equalTo: pageIndicatorView.bottomAnchor, constant: -6), + pageIndicatorLabel.leadingAnchor.constraint(equalTo: pageIndicatorView.leadingAnchor, constant: 10), + pageIndicatorLabel.trailingAnchor.constraint(equalTo: pageIndicatorView.trailingAnchor, constant: -10), + ]) + } setupImageViewConstraints() @@ -315,6 +398,52 @@ final class ImageViewerController: UIViewController { let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:))) panGesture.delegate = self view.addGestureRecognizer(panGesture) + + if imageItems.count > 1 { + let swipeLeft = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipeGesture(_:))) + swipeLeft.direction = .left + swipeLeft.delegate = self + view.addGestureRecognizer(swipeLeft) + + let swipeRight = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipeGesture(_:))) + swipeRight.direction = .right + swipeRight.delegate = self + view.addGestureRecognizer(swipeRight) + } + } + + private func updatePageIndicator() { + guard imageItems.count > 1 else { return } + pageIndicatorLabel.text = "\(currentIndex + 1) / \(imageItems.count)" + pageIndicatorView.isHidden = false + } + + private func updateSourceViewForCurrentItem() { + guard let sourceViewProvider, !imageItems.isEmpty else { return } + let itemId = imageItems[currentIndex].id + sourceView = sourceViewProvider(itemId) + if let sourceView { + sourceFrame = sourceView.convert(sourceView.bounds, to: nil) + } + } + + private func showImage(at index: Int, direction: UISwipeGestureRecognizer.Direction) { + guard index >= 0, index < imageItems.count else { return } + currentIndex = index + imageURL = imageItems[index].url + updateSourceViewForCurrentItem() + updatePageIndicator() + + let transition = CATransition() + transition.type = .push + transition.subtype = direction == .left ? .fromRight : .fromLeft + transition.duration = 0.25 + transition.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + mediaContainerView.layer.add(transition, forKey: "imageSwipe") + + scrollView.setZoomScale(scrollView.minimumZoomScale, animated: false) + imageView.imageView.image = nil + loadImage() } private func loadMedia() { @@ -329,19 +458,23 @@ final class ImageViewerController: UIViewController { private func loadImage() { guard let imageURL else { return } - if let sourceImage = sourceImage { + if let sourceImage = sourceImage, !didApplySourceImage { imageView.imageView.image = sourceImage updateImageViewConstraints() + didApplySourceImage = true } imageView.url = imageURL - - NotificationCenter.default.addObserver( - self, - selector: #selector(imageDidLoad), - name: .imageLoadingDidFinish, - object: nil - ) + + if !didRegisterImageObserver { + NotificationCenter.default.addObserver( + self, + selector: #selector(imageDidLoad), + name: .imageLoadingDidFinish, + object: nil + ) + didRegisterImageObserver = true + } } private func loadVideo() { @@ -522,6 +655,13 @@ final class ImageViewerController: UIViewController { // MARK: - Actions + @objc private func handleSwipeGesture(_ gesture: UISwipeGestureRecognizer) { + guard !isVideo, imageItems.count > 1 else { return } + let delta = gesture.direction == .left ? 1 : -1 + let nextIndex = currentIndex + delta + showImage(at: nextIndex, direction: gesture.direction) + } + @objc private func handleDoubleTap(_ gesture: UITapGestureRecognizer) { if scrollView.zoomScale > scrollView.minimumZoomScale { // If already zoomed in, zoom out @@ -792,6 +932,9 @@ extension ImageViewerController: UIScrollViewDelegate { extension ImageViewerController: UIGestureRecognizerDelegate { func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + if gestureRecognizer is UISwipeGestureRecognizer { + return !isVideo && imageItems.count > 1 && scrollView.zoomScale == scrollView.minimumZoomScale + } if let panGesture = gestureRecognizer as? UIPanGestureRecognizer { let velocity = panGesture.velocity(in: view) // Only allow vertical panning to dismiss when not zoomed in diff --git a/apple/InlineIOS/Features/Media/NewPhotoView.swift b/apple/InlineIOS/Features/Media/NewPhotoView.swift index 33c4cca5..f3f8ed57 100644 --- a/apple/InlineIOS/Features/Media/NewPhotoView.swift +++ b/apple/InlineIOS/Features/Media/NewPhotoView.swift @@ -58,6 +58,7 @@ final class NewPhotoView: UIView { }() private var imageConstraints: [NSLayoutConstraint] = [] + var onTap: ((FullMessage, UIView, UIImage?, URL) -> Void)? // MARK: - Initialization @@ -362,6 +363,11 @@ final class NewPhotoView: UIView { guard fullMessage.message.isSticker != true else { return } guard let url = imageLocalUrl() ?? imageCdnUrl() else { return } + if let onTap { + onTap(fullMessage, imageView, imageView.imageView.image, url) + return + } + let imageViewer = ImageViewerController( imageURL: url, sourceView: imageView, diff --git a/apple/InlineIOS/Features/Message/UIMessageView.swift b/apple/InlineIOS/Features/Message/UIMessageView.swift index d3a66bf3..751a86f9 100644 --- a/apple/InlineIOS/Features/Message/UIMessageView.swift +++ b/apple/InlineIOS/Features/Message/UIMessageView.swift @@ -30,6 +30,11 @@ class UIMessageView: UIView { private var shineEffectView: ShineEffectView? var linkTapHandler: ((URL) -> Void)? + var onPhotoTap: ((FullMessage, UIView, UIImage?, URL) -> Void)? { + didSet { + bindPhotoTapHandlerIfNeeded() + } + } var interaction: UIContextMenuInteraction? static let attributedCache: NSCache = { @@ -422,12 +427,26 @@ class UIMessageView: UIView { guard fullMessage.photoInfo != nil else { return } containerStack.addArrangedSubview(newPhotoView) + bindPhotoTapHandlerIfNeeded() if shouldShowFloatingMetadata { addFloatingMetadata(relativeTo: newPhotoView) } } + private func bindPhotoTapHandlerIfNeeded() { + guard fullMessage.photoInfo != nil else { return } + guard let onPhotoTap else { + newPhotoView.onTap = nil + return + } + + newPhotoView.onTap = { [weak self] message, sourceView, sourceImage, url in + guard let self else { return } + onPhotoTap(message, sourceView, sourceImage, url) + } + } + func setupVideoViewIfNeeded() { guard fullMessage.videoInfo != nil else { return }