diff --git a/Rosetta/Core/Data/Repositories/GroupRepository.swift b/Rosetta/Core/Data/Repositories/GroupRepository.swift index 22d992b..05d8215 100644 --- a/Rosetta/Core/Data/Repositories/GroupRepository.swift +++ b/Rosetta/Core/Data/Repositories/GroupRepository.swift @@ -39,7 +39,7 @@ final class GroupRepository { let description: String } - struct ParsedGroupInvite { + struct ParsedGroupInvite: Sendable { let groupId: String let title: String let encryptKey: String @@ -53,6 +53,11 @@ final class GroupRepository { } func normalizeGroupId(_ value: String) -> String { + Self.normalizeGroupIdPure(value) + } + + /// Thread-safe group ID normalization — strips `#group:`, `group:`, `conversation:` prefixes. + nonisolated static func normalizeGroupIdPure(_ value: String) -> String { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) let lower = trimmed.lowercased() if lower.hasPrefix("#group:") { @@ -193,6 +198,11 @@ final class GroupRepository { /// Parses an invite string into its components. func parseInviteString(_ inviteString: String) -> ParsedGroupInvite? { + Self.parseInviteStringPure(inviteString) + } + + /// Thread-safe (nonisolated) parsing — callable from background layout threads. + nonisolated static func parseInviteStringPure(_ inviteString: String) -> ParsedGroupInvite? { let trimmed = inviteString.trimmingCharacters(in: .whitespacesAndNewlines) let lower = trimmed.lowercased() @@ -211,7 +221,7 @@ final class GroupRepository { guard let decryptedPayload = try? CryptoManager.shared.decryptWithPassword( encodedPayload, - password: Self.groupInvitePassword + password: groupInvitePassword ), let payload = String(data: decryptedPayload, encoding: .utf8) else { return nil } @@ -219,7 +229,7 @@ final class GroupRepository { let parts = payload.split(separator: ":", omittingEmptySubsequences: false).map(String.init) guard parts.count >= 3 else { return nil } - let groupId = normalizeGroupId(parts[0]) + let groupId = normalizeGroupIdPure(parts[0]) guard !groupId.isEmpty else { return nil } return ParsedGroupInvite( @@ -230,6 +240,13 @@ final class GroupRepository { ) } + /// Fast local membership check — uses in-memory keyCache. MainActor only. + func hasGroup(for groupId: String) -> Bool { + let account = SessionManager.shared.currentPublicKey + let key = cacheKey(account: account, groupId: groupId) + return keyCache[key] != nil + } + // MARK: - Persistence /// Persists group from a joined `PacketGroupJoin` (server-pushed or self-initiated). diff --git a/Rosetta/Core/Layout/MessageCellLayout.swift b/Rosetta/Core/Layout/MessageCellLayout.swift index f5d844f..a8150e2 100644 --- a/Rosetta/Core/Layout/MessageCellLayout.swift +++ b/Rosetta/Core/Layout/MessageCellLayout.swift @@ -60,6 +60,12 @@ struct MessageCellLayout: Sendable { let hasFile: Bool let fileFrame: CGRect // File view frame in bubble coords + // MARK: - Group Invite (optional) + + let hasGroupInvite: Bool + let groupInviteTitle: String + let groupInviteGroupId: String + // MARK: - Forward Header (optional) let isForward: Bool @@ -90,6 +96,7 @@ struct MessageCellLayout: Sendable { case file case forward case emojiOnly + case groupInvite } } @@ -119,6 +126,9 @@ extension MessageCellLayout { let forwardCaption: String? let showsDateHeader: Bool let dateHeaderText: String + let groupInviteCount: Int + let groupInviteTitle: String + let groupInviteGroupId: String } private struct MediaDimensions { @@ -186,6 +196,8 @@ extension MessageCellLayout { messageType = .photo } else if config.fileCount > 0 || config.avatarCount > 0 || config.callCount > 0 { messageType = .file + } else if config.groupInviteCount > 0 { + messageType = .groupInvite } else if config.hasReplyQuote { messageType = .textWithReply } else { @@ -468,13 +480,24 @@ extension MessageCellLayout { fileOnlyTsPad = tsPad bubbleH += tsGap + tsSize.height + tsPad fileH = bubbleH // fileContainer spans entire bubble + } else if config.groupInviteCount > 0 { + // Group invite card: icon row + status + button + let inviteCardH: CGFloat = 80 + let inviteMinW: CGFloat = 220 + bubbleW = min(inviteMinW, effectiveMaxBubbleWidth) + bubbleW = max(bubbleW, leftPad + metadataWidth + rightPad) + let tsGap: CGFloat = 6 + let contentH: CGFloat = 60 + let tsPad = ceil((inviteCardH + tsGap - contentH) / 2) + fileOnlyTsPad = tsPad + bubbleH += inviteCardH + tsGap + tsSize.height + tsPad } else { // No text, no file (forward header only, empty) bubbleW = leftPad + metadataWidth + rightPad bubbleW = max(minW, min(bubbleW, effectiveMaxBubbleWidth)) } - if config.text.isEmpty && photoH == 0 && fileH == 0 && !config.isForward { + if config.text.isEmpty && photoH == 0 && fileH == 0 && !config.isForward && config.groupInviteCount == 0 { bubbleH = max(bubbleH, 37) } // Forward header needs minimum width for "Forwarded from" + avatar + name @@ -672,6 +695,9 @@ extension MessageCellLayout { photoCollageHeight: photoH, hasFile: config.fileCount > 0 || config.avatarCount > 0 || config.callCount > 0, fileFrame: fileFrame, + hasGroupInvite: config.groupInviteCount > 0, + groupInviteTitle: config.groupInviteTitle, + groupInviteGroupId: config.groupInviteGroupId, isForward: config.isForward, forwardHeaderFrame: fwdHeaderFrame, forwardAvatarFrame: fwdAvatarFrame, @@ -1001,13 +1027,24 @@ extension MessageCellLayout { AttachmentPreviewCodec.imageDimensions(from: $0.preview) } + // Detect group invite (only for text-only messages, no attachments) + let isGroupInvite = displayText.hasPrefix("#group:") + && images.isEmpty && files.isEmpty && avatars.isEmpty && calls.isEmpty && !isForward + var groupInviteTitle = "" + var groupInviteGroupId = "" + if isGroupInvite, let parsed = GroupRepository.parseInviteStringPure(displayText) { + groupInviteTitle = parsed.title + groupInviteGroupId = parsed.groupId + } + let groupInviteCount = (!groupInviteTitle.isEmpty) ? 1 : 0 + let config = Config( maxBubbleWidth: maxBubbleWidth, isOutgoing: isOutgoing, isDarkMode: isDarkMode, position: position, deliveryStatus: message.deliveryStatus, - text: isForward ? (forwardCaption ?? "") : displayText, + text: isForward ? (forwardCaption ?? "") : (groupInviteCount > 0 ? "" : displayText), timestampText: timestampText, hasReplyQuote: hasReply && !displayText.isEmpty, replyName: nil, @@ -1022,7 +1059,10 @@ extension MessageCellLayout { forwardFileCount: forwardInnerFileCount, forwardCaption: forwardCaption, showsDateHeader: showsDateHeader, - dateHeaderText: dateHeaderText + dateHeaderText: dateHeaderText, + groupInviteCount: groupInviteCount, + groupInviteTitle: groupInviteTitle, + groupInviteGroupId: groupInviteGroupId ) var (layout, textLayout) = calculate(config: config) diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index 45af35a..4e2cdbf 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -255,6 +255,16 @@ struct ChatDetailView: View { pendingGroupInviteTitle = parsed.title } } + cellActions.onGroupInviteOpen = { dialogKey in + let title = GroupRepository.shared.groupMetadata( + account: SessionManager.shared.currentPublicKey, + groupDialogKey: dialogKey + )?.title ?? "" + NotificationCenter.default.post( + name: .openChatFromNotification, + object: ChatRoute(groupDialogKey: dialogKey, title: title) + ) + } // Capture first unread incoming message BEFORE marking as read. if firstUnreadMessageId == nil { firstUnreadMessageId = messages.first(where: { diff --git a/Rosetta/Features/Chats/ChatDetail/GroupInviteCardView.swift b/Rosetta/Features/Chats/ChatDetail/GroupInviteCardView.swift new file mode 100644 index 0000000..a22a19a --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/GroupInviteCardView.swift @@ -0,0 +1,113 @@ +import SwiftUI + +/// Inline card for group invite links — renders inside message bubble (iOS 26+ SwiftUI path). +/// Matches Desktop `GroupInviteMessage` and Android `GroupInviteInlineCard` behavior. +struct GroupInviteCardView: View { + let inviteString: String + let title: String + let groupId: String + let isOutgoing: Bool + let actions: MessageCellActions + + @State private var status: CardStatus = .notJoined + + enum CardStatus { + case notJoined, joined, invalid, banned + + var color: Color { + switch self { + case .notJoined: Color(red: 0.14, green: 0.54, blue: 0.90) + case .joined: Color(red: 0.20, green: 0.78, blue: 0.35) + case .invalid, .banned: Color(red: 0.98, green: 0.23, blue: 0.19) + } + } + + var statusText: String { + switch self { + case .notJoined: "Invite to join this group" + case .joined: "You are a member" + case .invalid: "This invite is invalid" + case .banned: "You are banned" + } + } + + var buttonTitle: String { + switch self { + case .notJoined: "Join Group" + case .joined: "Open Group" + case .invalid: "Invalid" + case .banned: "Banned" + } + } + + var isActionable: Bool { + self == .notJoined || self == .joined + } + } + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Circle() + .fill(status.color) + .frame(width: 44, height: 44) + .overlay { + Image(systemName: "person.2.fill") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(.white) + } + + VStack(alignment: .leading, spacing: 3) { + Text(title.isEmpty ? "Group" : title) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(isOutgoing ? .white : Color(status.color)) + .lineLimit(1) + + Text(status.statusText) + .font(.system(size: 12)) + .foregroundStyle(isOutgoing ? .white.opacity(0.7) : .secondary) + .lineLimit(1) + + Button(action: handleTap) { + Text(status.buttonTitle) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white) + .padding(.horizontal, 14) + .padding(.vertical, 5) + .background(status.color, in: Capsule()) + } + .buttonStyle(.plain) + .disabled(!status.isActionable) + .opacity(status.isActionable ? 1.0 : 0.5) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .task { await checkStatus() } + } + + private func handleTap() { + if status == .joined { + let dialogKey = "#group:\(groupId)" + actions.onGroupInviteOpen(dialogKey) + } else if status == .notJoined { + actions.onGroupInviteTap(inviteString) + } + } + + private func checkStatus() async { + // Local check first + let isJoined = GroupRepository.shared.hasGroup(for: groupId) + if isJoined { status = .joined } + + // Server check for authoritative status + if let (_, serverStatus) = try? await GroupService.shared.checkInviteStatus(groupId: groupId) { + let newStatus: CardStatus = switch serverStatus { + case .joined: .joined + case .notJoined: .notJoined + case .invalid: .invalid + case .banned: .banned + } + status = newStatus + } + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift b/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift index 361cf13..df3bb52 100644 --- a/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift +++ b/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift @@ -5,18 +5,14 @@ import Photos // MARK: - Data Types -/// Per-image metadata for the gallery viewer. -/// Android parity: `ViewableImage` in `ImageViewerScreen.kt`. struct ViewableImageInfo: Equatable, Identifiable { let attachmentId: String let senderName: String let timestamp: Date let caption: String - var id: String { attachmentId } } -/// State for the image gallery viewer. struct ImageViewerState: Equatable { let images: [ViewableImageInfo] let initialIndex: Int @@ -28,16 +24,10 @@ struct ImageViewerState: Equatable { /// Manages the vertical pan gesture for gallery dismiss. /// Attached to the hosting controller's view (NOT as a SwiftUI overlay) so it /// doesn't block SwiftUI gestures (pinch zoom, taps) on the content below. -/// Previous approach: `HeroPanGestureOverlay` (UIViewRepresentable overlay) blocked -/// all SwiftUI gestures at the hit-test level — pinch zoom and double-tap never worked. final class GalleryDismissPanCoordinator: NSObject, ObservableObject, UIGestureRecognizerDelegate { - /// Current vertical drag offset during dismiss gesture. @Published var dragOffset: CGSize = .zero - /// Toggles on every pan-end event — use `.onChange` to react. @Published private(set) var panEndSignal: Bool = false - /// Y velocity at the moment the pan gesture ended (pt/s). private(set) var endVelocityY: CGFloat = 0 - /// Set to false to disable the dismiss gesture (e.g. when zoomed in). var isEnabled: Bool = true @objc func handlePan(_ gesture: UIPanGestureRecognizer) { @@ -45,49 +35,38 @@ final class GalleryDismissPanCoordinator: NSObject, ObservableObject, UIGestureR if gesture.state == .began { gesture.state = .cancelled } return } - let translation = gesture.translation(in: gesture.view) + let t = gesture.translation(in: gesture.view) switch gesture.state { case .began, .changed: - // Vertical only — Telegram parity (no diagonal drag). - dragOffset = CGSize(width: 0, height: translation.y) + dragOffset = CGSize(width: 0, height: t.y) case .ended, .cancelled: endVelocityY = gesture.velocity(in: gesture.view).y panEndSignal.toggle() - default: - break + default: break } } - // Only begin for downward vertical drags. - func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - guard isEnabled else { return false } - guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return false } + func gestureRecognizerShouldBegin(_ g: UIGestureRecognizer) -> Bool { + guard isEnabled, let pan = g as? UIPanGestureRecognizer else { return false } let v = pan.velocity(in: pan.view) return v.y > abs(v.x) } - // Allow simultaneous recognition with non-pan gestures (pinch, taps). - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer - ) -> Bool { - !(other is UIPanGestureRecognizer) + func gestureRecognizer(_ g: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith o: UIGestureRecognizer) -> Bool { + !(o is UIPanGestureRecognizer) } } // MARK: - ImageViewerPresenter -/// UIHostingController subclass that hides the status bar. private final class StatusBarHiddenHostingController: UIHostingController { override var prefersStatusBarHidden: Bool { true } override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation { .fade } } -/// Presents the image gallery viewer using UIKit `overFullScreen` presentation. -/// Telegram parity: the viewer appears as a fade overlay covering nav bar and tab bar. @MainActor final class ImageViewerPresenter { - static let shared = ImageViewerPresenter() private weak var presentedController: UIViewController? private var panCoordinator: GalleryDismissPanCoordinator? @@ -104,13 +83,10 @@ final class ImageViewerPresenter { onDismiss: { [weak self] in self?.dismiss() } ) - let hostingController = StatusBarHiddenHostingController(rootView: AnyView(viewer)) - hostingController.modalPresentationStyle = .overFullScreen - hostingController.view.backgroundColor = .clear + let hc = StatusBarHiddenHostingController(rootView: AnyView(viewer)) + hc.modalPresentationStyle = .overFullScreen + hc.view.backgroundColor = .clear - // Pan gesture on hosting controller's view — NOT a SwiftUI overlay. - // UIKit gesture recognizers on a hosting view coexist with SwiftUI gestures - // on child views (pinch, taps, TabView swipe) without blocking them. let pan = UIPanGestureRecognizer( target: coordinator, action: #selector(GalleryDismissPanCoordinator.handlePan) @@ -118,18 +94,15 @@ final class ImageViewerPresenter { pan.minimumNumberOfTouches = 1 pan.maximumNumberOfTouches = 1 pan.delegate = coordinator - hostingController.view.addGestureRecognizer(pan) + hc.view.addGestureRecognizer(pan) - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let root = windowScene.keyWindow?.rootViewController - else { return } + guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let root = scene.keyWindow?.rootViewController else { return } var presenter = root - while let presented = presenter.presentedViewController { - presenter = presented - } - presenter.present(hostingController, animated: false) - presentedController = hostingController + while let p = presenter.presentedViewController { presenter = p } + presenter.present(hc, animated: false) + presentedController = hc } func dismiss() { @@ -139,11 +112,16 @@ final class ImageViewerPresenter { } } +// MARK: - Window Safe Area Helper + +private var windowSafeArea: UIEdgeInsets { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first?.keyWindow?.safeAreaInsets ?? .zero +} + // MARK: - ImageGalleryViewer -/// Multi-photo gallery viewer with hero transition animation. -/// Telegram parity: hero open/close, vertical-only interactive dismiss, -/// slide-in panels, counter below name capsule, pinch zoom, double-tap zoom. struct ImageGalleryViewer: View { let state: ImageViewerState @@ -155,13 +133,13 @@ struct ImageGalleryViewer: View { @State private var currentZoomScale: CGFloat = 1.0 @State private var isDismissing = false @State private var isExpanded: Bool = false - @State private var viewSize: CGSize = UIScreen.main.bounds.size + + private let screenSize = UIScreen.main.bounds.size private static let dateFormatter: DateFormatter = { let f = DateFormatter() f.dateStyle = .none f.timeStyle = .short - f.doesRelativeDateFormatting = true return f }() @@ -188,102 +166,75 @@ struct ImageGalleryViewer: View { .interpolatingSpring(duration: 0.3, bounce: 0, initialVelocity: 0) } - /// Background opacity: fades over 80pt drag (Telegram: `abs(distance) / 80`). private var backgroundOpacity: CGFloat { let progress = min(abs(panCoordinator.dragOffset.height) / 80, 1) return isExpanded ? max(1 - progress, 0) : 0 } - /// Overlay/toolbar opacity: fades over 50pt drag (Telegram: `abs(distance) / 50`). private var overlayDragOpacity: CGFloat { 1 - min(abs(panCoordinator.dragOffset.height) / 50, 1) } private func formattedDate(_ date: Date) -> String { - let dayPart = Self.relativeDateFormatter.string(from: date) - let timePart = Self.dateFormatter.string(from: date) - return "\(dayPart) at \(timePart)" + let day = Self.relativeDateFormatter.string(from: date) + let time = Self.dateFormatter.string(from: date) + return "\(day) at \(time)" } // MARK: - Body var body: some View { - let sourceFrame = state.sourceFrame + let sf = state.sourceFrame - GeometryReader { geometry in - let size = geometry.size + TabView(selection: $currentPage) { + ForEach(Array(state.images.enumerated()), id: \.element.attachmentId) { index, info in + let isHeroPage = index == state.initialIndex + let heroActive = isHeroPage && !isExpanded - TabView(selection: $currentPage) { - ForEach(Array(state.images.enumerated()), id: \.element.attachmentId) { index, info in - // Hero frame/offset only on the initial page — other pages are - // always fullscreen. Prevents glitch where lazily-created pages - // briefly render at sourceFrame size during TabView swipe. - let isHeroPage = index == state.initialIndex - let heroActive = isHeroPage && !isExpanded - - ZoomableImagePage( - attachmentId: info.attachmentId, - onDismiss: { dismissAction() }, - showControls: $showControls, - currentScale: $currentZoomScale, - onEdgeTap: { direction in navigateEdgeTap(direction: direction) } - ) - .frame( - width: heroActive ? sourceFrame.width : size.width, - height: heroActive ? sourceFrame.height : size.height - ) - .clipped() - .offset( - x: heroActive ? sourceFrame.minX : 0, - y: heroActive ? sourceFrame.minY : 0 - ) - .offset(panCoordinator.dragOffset) - .frame( - maxWidth: .infinity, - maxHeight: .infinity, - alignment: heroActive ? .topLeading : .center - ) - .tag(index) - } + ZoomableImagePage( + attachmentId: info.attachmentId, + onDismiss: { dismissAction() }, + showControls: $showControls, + currentScale: $currentZoomScale, + onEdgeTap: { dir in navigateEdgeTap(direction: dir) } + ) + // Hero page: animate from sourceFrame → fullscreen. + // Non-hero pages: NO explicit frame — fill TabView page naturally. + .modifier(GalleryPageModifier( + heroActive: heroActive, + sourceFrame: sf, + fullSize: screenSize, + dragOffset: panCoordinator.dragOffset + )) + .tag(index) } - .tabViewStyle(.page(indexDisplayMode: .never)) - .scrollDisabled(currentZoomScale > 1.05 || isDismissing) - .contentShape(Rectangle()) - .overlay { galleryOverlay } - .background { - Color.black - .opacity(backgroundOpacity) - } - .allowsHitTesting(isExpanded) - .onAppear { viewSize = size } } + .tabViewStyle(.page(indexDisplayMode: .never)) .ignoresSafeArea() + .scrollDisabled(currentZoomScale > 1.05 || isDismissing) + .contentShape(Rectangle()) + .overlay { galleryOverlay } + .background { + Color.black.opacity(backgroundOpacity).ignoresSafeArea() + } + .allowsHitTesting(isExpanded) .statusBarHidden(true) .task { prefetchAdjacentImages(around: state.initialIndex) guard !isExpanded else { return } - withAnimation(heroAnimation) { - isExpanded = true - } - } - .onChange(of: currentPage) { _, newPage in - prefetchAdjacentImages(around: newPage) - } - .onChange(of: currentZoomScale) { _, newScale in - panCoordinator.isEnabled = newScale <= 1.05 - } - .onChange(of: panCoordinator.panEndSignal) { _, _ in - handlePanEnd() + withAnimation(heroAnimation) { isExpanded = true } } + .onChange(of: currentPage) { _, p in prefetchAdjacentImages(around: p) } + .onChange(of: currentZoomScale) { _, s in panCoordinator.isEnabled = s <= 1.05 } + .onChange(of: panCoordinator.panEndSignal) { _, _ in handlePanEnd() } } - // MARK: - Pan End Handler + // MARK: - Pan End private func handlePanEnd() { - let offsetY = panCoordinator.dragOffset.height - let velocityY = panCoordinator.endVelocityY - // Telegram parity: dismiss on 50pt drag OR fast downward flick (>1000 pt/s). - if offsetY > 50 || velocityY > 1000 { + let y = panCoordinator.dragOffset.height + let v = panCoordinator.endVelocityY + if y > 50 || v > 1000 { dismissAction() } else { withAnimation(heroAnimation.speed(1.2)) { @@ -292,13 +243,15 @@ struct ImageGalleryViewer: View { } } - // MARK: - Gallery Overlay (Telegram parity) + // MARK: - Overlay (Telegram parity) @ViewBuilder private var galleryOverlay: some View { + let sa = windowSafeArea + if !isDismissing && isExpanded { ZStack { - // Top panel — slides DOWN from above on show, UP on hide + // Top panel VStack(spacing: 4) { topPanel if state.images.count > 1 { @@ -307,15 +260,17 @@ struct ImageGalleryViewer: View { Spacer() } .frame(maxWidth: .infinity) - .offset(y: showControls ? 0 : -120) + .padding(.top, sa.top > 0 ? sa.top : 20) + .offset(y: showControls ? 0 : -(sa.top + 120)) .allowsHitTesting(showControls) - // Bottom panel — slides UP from below on show, DOWN on hide + // Bottom panel VStack { Spacer() bottomPanel + .padding(.bottom, sa.bottom > 0 ? sa.bottom : 16) } - .offset(y: showControls ? 0 : 120) + .offset(y: showControls ? 0 : (sa.bottom + 120)) .allowsHitTesting(showControls) } .compositingGroup() @@ -325,15 +280,15 @@ struct ImageGalleryViewer: View { } } - // MARK: - Top Panel + // MARK: - Top Panel (Telegram parity) private var topPanel: some View { - HStack { + HStack(alignment: .top) { glassCircleButton(systemName: "chevron.left") { dismissAction() } Spacer(minLength: 0) glassCircleButton(systemName: "ellipsis") { } } - .overlay { + .overlay(alignment: .top) { if let info = currentInfo { VStack(spacing: 2) { Text(info.senderName) @@ -351,11 +306,10 @@ struct ImageGalleryViewer: View { .animation(.easeInOut, value: currentPage) } } - .padding(.horizontal, 15) - .padding(.top, 4) + .padding(.horizontal, 16) } - // MARK: - Counter Badge (below name capsule) + // MARK: - Counter private var counterBadge: some View { Text("\(currentPage + 1) of \(state.images.count)") @@ -364,32 +318,44 @@ struct ImageGalleryViewer: View { .padding(.horizontal, 12) .padding(.vertical, 4) .background { TelegramGlassCapsule() } + .padding(.top, 6) .contentTransition(.numericText()) .animation(.easeInOut, value: currentPage) } - // MARK: - Bottom Panel + // MARK: - Bottom Panel (Telegram parity) + // Telegram: Forward (left) — [draw | caption center] — Delete (right) + // Rosetta: Forward — Share — Save — Delete private var bottomPanel: some View { - HStack { + HStack(spacing: 0) { + // Forward glassCircleButton(systemName: "arrowshape.turn.up.right") { } - Spacer(minLength: 0) + + Spacer() + + // Share glassCircleButton(systemName: "square.and.arrow.up") { shareCurrentImage() } - Spacer(minLength: 0) + + Spacer() + + // Save to Photos glassCircleButton(systemName: "square.and.arrow.down") { saveCurrentImage() } - Spacer(minLength: 0) + + Spacer() + + // Delete glassCircleButton(systemName: "trash") { } } - .padding(.horizontal, 15) - .padding(.bottom, 8) + .padding(.horizontal, 16) } - // MARK: - Glass Circle Button + // MARK: - Glass Button private func glassCircleButton(systemName: String, action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: systemName) - .font(.system(size: 17, weight: .medium)) + .font(.system(size: 22)) .foregroundStyle(.white) .frame(width: 44, height: 44) } @@ -399,16 +365,14 @@ struct ImageGalleryViewer: View { // MARK: - Navigation private func navigateEdgeTap(direction: Int) { - let target = currentPage + direction - guard target >= 0, target < state.images.count else { return } - currentPage = target + let t = currentPage + direction + guard t >= 0, t < state.images.count else { return } + currentPage = t } // MARK: - Dismiss private func dismissAction() { - // Telegram parity: hero-back only for the initially-tapped photo. - // If user paged away, the sourceFrame belongs to a different thumbnail — fade instead. if currentZoomScale > 1.05 || currentPage != state.initialIndex { fadeDismiss() } else { @@ -420,7 +384,6 @@ struct ImageGalleryViewer: View { guard !isDismissing else { return } isDismissing = true panCoordinator.isEnabled = false - Task { withAnimation(heroAnimation.speed(1.2)) { panCoordinator.dragOffset = .zero @@ -435,16 +398,10 @@ struct ImageGalleryViewer: View { guard !isDismissing else { return } isDismissing = true panCoordinator.isEnabled = false - - // Slide down + fade out via dragOffset (drives backgroundOpacity toward 0). - // Do NOT set isExpanded=false — that collapses to sourceFrame (wrong thumbnail). withAnimation(.easeOut(duration: 0.25)) { - panCoordinator.dragOffset = CGSize(width: 0, height: viewSize.height * 0.4) - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.27) { - onDismiss() + panCoordinator.dragOffset = CGSize(width: 0, height: screenSize.height * 0.4) } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.27) { onDismiss() } } // MARK: - Actions @@ -453,20 +410,14 @@ struct ImageGalleryViewer: View { guard let info = currentInfo, let image = AttachmentCache.shared.loadImage(forAttachmentId: info.attachmentId) else { return } - - let activityVC = UIActivityViewController(activityItems: [image], applicationActivities: nil) - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let root = windowScene.keyWindow?.rootViewController { - var presenter = root - while let presented = presenter.presentedViewController { - presenter = presented - } - activityVC.popoverPresentationController?.sourceView = presenter.view - activityVC.popoverPresentationController?.sourceRect = CGRect( - x: presenter.view.bounds.midX, y: presenter.view.bounds.maxY - 50, - width: 0, height: 0 - ) - presenter.present(activityVC, animated: true) + let vc = UIActivityViewController(activityItems: [image], applicationActivities: nil) + if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let root = scene.keyWindow?.rootViewController { + var p = root; while let pp = p.presentedViewController { p = pp } + vc.popoverPresentationController?.sourceView = p.view + vc.popoverPresentationController?.sourceRect = CGRect( + x: p.view.bounds.midX, y: p.view.bounds.maxY - 50, width: 0, height: 0) + p.present(vc, animated: true) } } @@ -474,7 +425,6 @@ struct ImageGalleryViewer: View { guard let info = currentInfo, let image = AttachmentCache.shared.loadImage(forAttachmentId: info.attachmentId) else { return } - PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in guard status == .authorized || status == .limited else { return } UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) @@ -487,13 +437,39 @@ struct ImageGalleryViewer: View { for offset in [-2, -1, 1, 2] { let i = index + offset guard i >= 0, i < state.images.count else { continue } - let attachmentId = state.images[i].attachmentId - guard AttachmentCache.shared.cachedImage(forAttachmentId: attachmentId) == nil else { continue } + let aid = state.images[i].attachmentId + guard AttachmentCache.shared.cachedImage(forAttachmentId: aid) == nil else { continue } Task.detached(priority: .utility) { await ImageLoadLimiter.shared.acquire() - _ = AttachmentCache.shared.loadImage(forAttachmentId: attachmentId) + _ = AttachmentCache.shared.loadImage(forAttachmentId: aid) await ImageLoadLimiter.shared.release() } } } } + +// MARK: - GalleryPageModifier + +/// Applies hero transition frame/offset ONLY for the initial page. +/// Non-hero pages have NO explicit frame — they fill the TabView page naturally, +/// which fixes the "tiny image" bug caused by explicit frame fighting with TabView sizing. +private struct GalleryPageModifier: ViewModifier { + let heroActive: Bool + let sourceFrame: CGRect + let fullSize: CGSize + let dragOffset: CGSize + + func body(content: Content) -> some View { + if heroActive { + content + .frame(width: sourceFrame.width, height: sourceFrame.height) + .clipped() + .offset(x: sourceFrame.minX, y: sourceFrame.minY) + .offset(dragOffset) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } else { + content + .offset(dragOffset) + } + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift b/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift index 0cb89c7..793baea 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift @@ -15,4 +15,5 @@ final class MessageCellActions { var onRemove: (ChatMessage) -> Void = { _ in } var onCall: (String) -> Void = { _ in } // peer public key var onGroupInviteTap: (String) -> Void = { _ in } // invite string + var onGroupInviteOpen: (String) -> Void = { _ in } // group dialog key → navigate } diff --git a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift index f25006f..71ff8dc 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift @@ -59,7 +59,8 @@ struct MessageCellView: View, Equatable { } .modifier(ConditionalSwipeToReply( enabled: !isSavedMessages && !isSystemAccount - && !message.attachments.contains(where: { $0.type == .avatar }), + && !message.attachments.contains(where: { $0.type == .avatar }) + && !message.text.hasPrefix("#group:"), onReply: { actions.onReply(message) } )) .overlay { @@ -96,6 +97,32 @@ struct MessageCellView: View, Equatable { message: message, reply: reply, outgoing: outgoing, hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position ) + } else if message.text.hasPrefix("#group:"), + let parsed = GroupRepository.parseInviteStringPure(message.text) { + GroupInviteCardView( + inviteString: message.text, + title: parsed.title, + groupId: parsed.groupId, + isOutgoing: outgoing, + actions: actions + ) + .frame(width: min(220, maxBubbleWidth)) + .overlay(alignment: .bottomTrailing) { + timestampOverlay(message: message, outgoing: outgoing) + } + .padding(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0) + .padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0) + .background { bubbleBackground(outgoing: outgoing, position: position) } + .overlay { + BubbleContextMenuOverlay( + items: contextMenuItems(for: message), + previewShape: MessageBubbleShape(position: position, outgoing: outgoing), + isOutgoing: outgoing, + replyQuoteHeight: 0, + onReplyQuoteTap: nil + ) + } + .frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading) } else { VStack(alignment: .leading, spacing: 0) { if let reply = replyData { diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index b032f92..83a2c62 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -183,6 +183,21 @@ final class NativeMessageCell: UICollectionViewCell { private let senderAvatarImageView = UIImageView() private let senderAvatarInitialLabel = UILabel() + // Group Invite Card + private let groupInviteContainer = UIView() + private let groupInviteIconBg = UIView() + private let groupInviteIcon = UIImageView() + private let groupInviteTitleLabel = UILabel() + private let groupInviteStatusLabel = UILabel() + private let groupInviteButton = UIButton(type: .custom) + private var groupInviteString: String? + private var currentInviteStatus: InviteCardStatus = .notJoined + private var inviteStatusTask: Task? + + enum InviteCardStatus { + case notJoined, joined, invalid, banned + } + // Highlight overlay (scroll-to-message flash) private let highlightOverlay = UIView() @@ -434,6 +449,35 @@ final class NativeMessageCell: UICollectionViewCell { bubbleView.addSubview(fileContainer) + // Group Invite Card + groupInviteIconBg.layer.cornerRadius = 22 + groupInviteIconBg.clipsToBounds = true + groupInviteIcon.image = UIImage( + systemName: "person.2.fill", + withConfiguration: UIImage.SymbolConfiguration(pointSize: 18, weight: .medium) + ) + groupInviteIcon.tintColor = .white + groupInviteIcon.contentMode = .scaleAspectFit + groupInviteIconBg.addSubview(groupInviteIcon) + groupInviteContainer.addSubview(groupInviteIconBg) + + groupInviteTitleLabel.font = .systemFont(ofSize: 15, weight: .semibold) + groupInviteTitleLabel.lineBreakMode = .byTruncatingTail + groupInviteContainer.addSubview(groupInviteTitleLabel) + + groupInviteStatusLabel.font = .systemFont(ofSize: 12, weight: .regular) + groupInviteContainer.addSubview(groupInviteStatusLabel) + + groupInviteButton.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium) + groupInviteButton.layer.cornerRadius = 14 + groupInviteButton.clipsToBounds = true + groupInviteButton.contentEdgeInsets = UIEdgeInsets(top: 5, left: 14, bottom: 5, right: 14) + groupInviteButton.addTarget(self, action: #selector(groupInviteCardTapped), for: .touchUpInside) + groupInviteContainer.addSubview(groupInviteButton) + + groupInviteContainer.isHidden = true + bubbleView.addSubview(groupInviteContainer) + // Listen for avatar download trigger (tap-to-download, Android parity) NotificationCenter.default.addObserver( self, selector: #selector(handleAttachmentDownload(_:)), @@ -822,6 +866,89 @@ final class NativeMessageCell: UICollectionViewCell { } else { fileContainer.isHidden = true } + + // Group Invite Card + if let layout = currentLayout, layout.hasGroupInvite { + groupInviteContainer.isHidden = false + textLabel.isHidden = true + groupInviteString = message.text + + groupInviteTitleLabel.text = layout.groupInviteTitle.isEmpty ? "Group" : layout.groupInviteTitle + + // Local membership check (fast, MainActor) + let isJoined = GroupRepository.shared.hasGroup(for: layout.groupInviteGroupId) + applyGroupInviteStatus(isJoined ? .joined : .notJoined, isOutgoing: layout.isOutgoing) + + // Async server check for authoritative status + let msgId = message.id, groupId = layout.groupInviteGroupId + inviteStatusTask?.cancel() + inviteStatusTask = Task { @MainActor [weak self] in + guard !Task.isCancelled else { return } + guard let self, self.message?.id == msgId else { return } + if let (_, status) = try? await GroupService.shared.checkInviteStatus(groupId: groupId), + !Task.isCancelled, + self.message?.id == msgId { + let cardStatus: InviteCardStatus = switch status { + case .joined: .joined + case .notJoined: .notJoined + case .invalid: .invalid + case .banned: .banned + } + self.applyGroupInviteStatus(cardStatus, isOutgoing: layout.isOutgoing) + } + } + } else { + groupInviteContainer.isHidden = true + } + } + + private func applyGroupInviteStatus(_ status: InviteCardStatus, isOutgoing: Bool) { + currentInviteStatus = status + let color: UIColor + switch status { + case .notJoined: color = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 1) // #248AE6 + case .joined: color = UIColor(red: 0.20, green: 0.78, blue: 0.35, alpha: 1) // #34C759 + case .invalid, .banned: color = UIColor(red: 0.98, green: 0.23, blue: 0.19, alpha: 1) + } + groupInviteIconBg.backgroundColor = color + groupInviteTitleLabel.textColor = isOutgoing ? .white : color + + let statusText: String + switch status { + case .notJoined: statusText = "Invite to join this group" + case .joined: statusText = "You are a member" + case .invalid: statusText = "This invite is invalid" + case .banned: statusText = "You are banned" + } + groupInviteStatusLabel.text = statusText + groupInviteStatusLabel.textColor = isOutgoing + ? UIColor.white.withAlphaComponent(0.7) + : .secondaryLabel + + let buttonTitle: String + switch status { + case .notJoined: buttonTitle = "Join Group" + case .joined: buttonTitle = "Open Group" + case .invalid: buttonTitle = "Invalid" + case .banned: buttonTitle = "Banned" + } + groupInviteButton.setTitle(buttonTitle, for: .normal) + groupInviteButton.setTitleColor(.white, for: .normal) + groupInviteButton.backgroundColor = color + groupInviteButton.isEnabled = (status == .notJoined || status == .joined) + groupInviteButton.alpha = groupInviteButton.isEnabled ? 1.0 : 0.5 + } + + @objc private func groupInviteCardTapped() { + guard let inviteStr = groupInviteString, let actions else { return } + if currentInviteStatus == .joined { + if let parsed = GroupRepository.parseInviteStringPure(inviteStr) { + let dialogKey = "#group:\(parsed.groupId)" + actions.onGroupInviteOpen(dialogKey) + } + } else if currentInviteStatus == .notJoined { + actions.onGroupInviteTap(inviteStr) + } } /// Apply pre-calculated layout (main thread only — just sets frames). @@ -1009,6 +1136,23 @@ final class NativeMessageCell: UICollectionViewCell { } } + // Group Invite Card + groupInviteContainer.isHidden = !layout.hasGroupInvite + if layout.hasGroupInvite { + groupInviteContainer.frame = CGRect(x: 0, y: 0, width: layout.bubbleSize.width, height: layout.bubbleSize.height) + let cW = layout.bubbleSize.width + let topY: CGFloat = 10 + groupInviteIconBg.frame = CGRect(x: 10, y: topY, width: 44, height: 44) + groupInviteIcon.frame = CGRect(x: 12, y: 12, width: 20, height: 20) + let textX: CGFloat = 64 + let textW = cW - textX - 10 + groupInviteTitleLabel.frame = CGRect(x: textX, y: topY + 2, width: textW, height: 19) + groupInviteStatusLabel.frame = CGRect(x: textX, y: topY + 22, width: textW, height: 16) + let btnSize = groupInviteButton.sizeThatFits(CGSize(width: 200, height: 28)) + let btnW = min(btnSize.width + 28, textW) + groupInviteButton.frame = CGRect(x: textX, y: topY + 42, width: btnW, height: 28) + } + // Forward if layout.isForward { forwardLabel.frame = layout.forwardHeaderFrame @@ -1100,6 +1244,9 @@ final class NativeMessageCell: UICollectionViewCell { if layout.hasFile { fileContainer.frame.origin.y += senderNameShift } + if layout.hasGroupInvite { + groupInviteContainer.frame.origin.y += senderNameShift + } if layout.isForward { forwardLabel.frame.origin.y += senderNameShift forwardAvatarView.frame.origin.y += senderNameShift @@ -1354,7 +1501,8 @@ final class NativeMessageCell: UICollectionViewCell { @objc private func handleSwipe(_ gesture: UIPanGestureRecognizer) { if isSavedMessages || isSystemAccount { return } - let isReplyBlocked = message?.attachments.contains(where: { $0.type == .avatar }) ?? false + let isReplyBlocked = (message?.attachments.contains(where: { $0.type == .avatar }) ?? false) + || (currentLayout?.messageType == .groupInvite) if isReplyBlocked { return } let translation = gesture.translation(in: contentView) @@ -2374,6 +2522,11 @@ final class NativeMessageCell: UICollectionViewCell { fileContainer.isHidden = true callArrowView.isHidden = true callBackButton.isHidden = true + groupInviteContainer.isHidden = true + groupInviteString = nil + currentInviteStatus = .notJoined + inviteStatusTask?.cancel() + inviteStatusTask = nil avatarImageView.image = nil avatarImageView.isHidden = true fileIconView.isHidden = false diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index 3d4793d..9337297 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -125,6 +125,7 @@ final class NativeMessageListController: UIViewController { private var datePillPool: [(container: UIView, label: UILabel)] = [] private var dateHideTimer: Timer? private var areDatePillsVisible = false + private var animatePillFrames = false // MARK: - Empty State (UIKit-managed, animates with keyboard) private var emptyStateHosting: UIHostingController? @@ -693,10 +694,20 @@ final class NativeMessageListController: UIViewController { let sections = sectionMap.map { DateSection(text: $0.key, topY: $0.value.topY, bottomY: $0.value.bottomY) } .sorted { $0.topY < $1.topY } - // 2. Position each section's pill using Telegram's formula. + // 2. Expand pool if more sections than pills (short chats spanning many days). + while datePillPool.count < sections.count { + let pill = makeDatePill() + if let composer = composerView { + view.insertSubview(pill.container, belowSubview: composer) + } else { + view.addSubview(pill.container) + } + datePillPool.append(pill) + } + + // 3. Position each section's pill using Telegram's formula. var usedPillCount = 0 for section in sections { - guard usedPillCount < datePillPool.count else { break } // Telegram formula: headerY = min(max(sectionTop, stickyY), sectionBottom - pillH) // +9 = vertically centered in 42pt dateHeaderHeight: (42 - 24) / 2 = 9 @@ -711,7 +722,7 @@ final class NativeMessageListController: UIViewController { let pill = datePillPool[usedPillCount] - // Prevent resize animation when reusing pool pill with different text. + // Phase 1: Internal properties — always snap (no animation). CATransaction.begin() CATransaction.setDisableActions(true) pill.label.text = section.text @@ -723,20 +734,34 @@ final class NativeMessageListController: UIViewController { x: round((screenW - pillW) / 2), y: headerY, width: pillW, height: pillH ) - pill.container.frame = pillFrame pill.container.layer.cornerRadius = pillH / 2 - pill.label.frame = pill.container.bounds - // Explicitly set glass frame (no autoresizingMask — prevents 1-frame inflation). - if let glass = pill.container.subviews.first(where: { $0.tag == 42 }) { - glass.frame = pill.container.bounds - glass.layoutIfNeeded() - } pill.container.isHidden = false // Natural-position pills always visible. Stuck pills fade with timer. pill.container.alpha = isStuck ? (areDatePillsVisible ? 1 : 0) : 1 pill.container.tag = isStuck ? 1 : 0 CATransaction.commit() + // Phase 2: Container frame — animate during keyboard, snap otherwise. + if animatePillFrames { + // Inside UIView.animate block → implicit animation matches keyboard curve. + pill.container.frame = pillFrame + } else { + CATransaction.begin() + CATransaction.setDisableActions(true) + pill.container.frame = pillFrame + CATransaction.commit() + } + + // Phase 3: Sync child frames to container (always snap). + CATransaction.begin() + CATransaction.setDisableActions(true) + pill.label.frame = pill.container.bounds + if let glass = pill.container.subviews.first(where: { $0.tag == 42 }) { + glass.frame = pill.container.bounds + glass.layoutIfNeeded() + } + CATransaction.commit() + usedPillCount += 1 } @@ -867,6 +892,10 @@ final class NativeMessageListController: UIViewController { // Capture visible cell positions BEFORE applying snapshot (for position animation) var oldPositions: [String: CGFloat] = [:] + // Capture pill positions for matching spring animation + let oldPillPositions = isInteractive + ? datePillPool.map { (y: $0.container.layer.position.y, visible: !$0.container.isHidden) } + : [] if isInteractive { for ip in collectionView.indexPathsForVisibleItems { if let cellId = dataSource.itemIdentifier(for: ip), @@ -901,6 +930,30 @@ final class NativeMessageListController: UIViewController { if isInteractive { collectionView.layoutIfNeeded() applyInsertionAnimations(newIds: newIds, oldPositions: oldPositions) + + // Animate date pills with same spring as cells + updateFloatingDateHeader() + for (i, pill) in datePillPool.enumerated() { + guard i < oldPillPositions.count, + !pill.container.isHidden, + oldPillPositions[i].visible else { continue } + let dy = oldPillPositions[i].y - pill.container.layer.position.y + guard abs(dy) > 0.5 else { continue } + + let move = CASpringAnimation(keyPath: "position.y") + move.fromValue = dy + move.toValue = 0.0 + move.isAdditive = true + move.stiffness = 555.0 + move.damping = 47.0 + move.mass = 1.0 + move.initialVelocity = 0 + move.duration = move.settlingDuration + move.fillMode = .backwards + pill.container.layer.add(move, forKey: "pillInsertionMove") + } + } else { + updateFloatingDateHeader() } if !hasCompletedInitialLoad && !messages.isEmpty { @@ -1186,8 +1239,15 @@ final class NativeMessageListController: UIViewController { } self.view.layoutIfNeeded() + + // Position pills at FINAL positions — implicit animation from + // UIView.animate matches keyboard curve/duration automatically. + self.animatePillFrames = true + self.updateFloatingDateHeader() + self.animatePillFrames = false }, completion: { _ in self.isKeyboardAnimating = false + self.updateFloatingDateHeader() }) } @@ -1224,7 +1284,9 @@ final class NativeMessageListController: UIViewController { extension NativeMessageListController: UICollectionViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { - updateFloatingDateHeader() + if !isKeyboardAnimating { + updateFloatingDateHeader() + } let offsetFromBottom = scrollView.contentOffset.y + scrollView.contentInset.top let isAtBottom = offsetFromBottom < 50 diff --git a/Rosetta/Features/Chats/ChatDetail/TelegramContextMenuController.swift b/Rosetta/Features/Chats/ChatDetail/TelegramContextMenuController.swift index a606b77..9f6b4a6 100644 --- a/Rosetta/Features/Chats/ChatDetail/TelegramContextMenuController.swift +++ b/Rosetta/Features/Chats/ChatDetail/TelegramContextMenuController.swift @@ -33,13 +33,25 @@ enum TelegramContextMenuBuilder { )) } - // Desktop parity: detect #group: invite strings and offer "Join Group" action. + // Desktop parity: detect #group: invite strings and offer "Join/Open Group" action. if message.text.hasPrefix("#group:") { + let isJoined: Bool + if let parsed = GroupRepository.parseInviteStringPure(message.text) { + isJoined = GroupRepository.shared.hasGroup(for: parsed.groupId) + } else { + isJoined = false + } items.append(TelegramContextMenuItem( - title: "Join Group", - iconName: "person.2.badge.plus", + title: isJoined ? "Open Group" : "Join Group", + iconName: isJoined ? "person.2" : "person.2.badge.plus", isDestructive: false, - handler: { actions.onGroupInviteTap(message.text) } + handler: { + if isJoined, let parsed = GroupRepository.parseInviteStringPure(message.text) { + actions.onGroupInviteOpen("#group:\(parsed.groupId)") + } else { + actions.onGroupInviteTap(message.text) + } + } )) } diff --git a/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift b/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift index 92a57c6..90bc924 100644 --- a/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift +++ b/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift @@ -3,9 +3,9 @@ import UIKit // MARK: - ZoomableImagePage -/// Single page in the image gallery viewer with UIKit-based gesture handling. -/// Android parity: `ZoomableImage` in `ImageViewerScreen.kt` — centroid-based pinch zoom, -/// double-tap to tap point, velocity-based dismiss, axis locking, edge tap navigation. +/// Single page in the image gallery viewer. +/// Uses GeometryReader + explicit frame calculation (Telegram parity) instead of +/// `.scaledToFit()` which is unreliable inside TabView `.page` style overlay chains. struct ZoomableImagePage: View { let attachmentId: String @@ -23,81 +23,73 @@ struct ZoomableImagePage: View { var body: some View { let effectiveScale = zoomScale * pinchScale - // Color.clear always fills ALL proposed space from the parent — TabView page, - // hero frame, etc. The Image in .overlay sizes relative to Color.clear's actual - // rendered frame. Previous approach (.scaledToFit + .frame(maxWidth: .infinity)) - // sometimes got a stale/zero proposed size from TabView lazy page creation, - // causing the image to render at thumbnail size. - Color.clear - .overlay { - if let image { - Image(uiImage: image) - .resizable() - .scaledToFit() - .scaleEffect(effectiveScale) - .offset( - x: effectiveScale > 1.05 ? zoomOffset.width : 0, - y: effectiveScale > 1.05 ? zoomOffset.height : 0 - ) + GeometryReader { geo in + let viewSize = geo.size + + if let image { + let fitted = fittedSize(image.size, in: viewSize) + + Image(uiImage: image) + .resizable() + .frame(width: fitted.width, height: fitted.height) + .scaleEffect(effectiveScale) + .offset( + x: effectiveScale > 1.05 ? zoomOffset.width : 0, + y: effectiveScale > 1.05 ? zoomOffset.height : 0 + ) + .position(x: viewSize.width / 2, y: viewSize.height / 2) + } else { + placeholder + .position(x: viewSize.width / 2, y: viewSize.height / 2) + } + } + .contentShape(Rectangle()) + .onTapGesture(count: 2) { + withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) { + if zoomScale > 1.1 { + zoomScale = 1.0 + zoomOffset = .zero } else { - placeholder + zoomScale = 2.5 } + currentScale = zoomScale } - .contentShape(Rectangle()) - // Double tap: zoom to 2.5x or reset (MUST be before single tap) - .onTapGesture(count: 2) { - withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) { - if zoomScale > 1.1 { - zoomScale = 1.0 - zoomOffset = .zero - } else { - zoomScale = 2.5 - } - currentScale = zoomScale + } + .onTapGesture { location in + let width = UIScreen.main.bounds.width + let edgeZone = width * 0.20 + if location.x < edgeZone { + onEdgeTap?(-1) + } else if location.x > width - edgeZone { + onEdgeTap?(1) + } else { + showControls.toggle() + } + } + .simultaneousGesture( + MagnifyGesture() + .updating($pinchScale) { value, state, _ in + state = value.magnification } - } - // Single tap: toggle controls / edge navigation - .onTapGesture { location in - let width = UIScreen.main.bounds.width - let edgeZone = width * 0.20 - if location.x < edgeZone { - onEdgeTap?(-1) - } else if location.x > width - edgeZone { - onEdgeTap?(1) - } else { - showControls.toggle() - } - } - // Pinch zoom - .simultaneousGesture( - MagnifyGesture() - .updating($pinchScale) { value, state, _ in - state = value.magnification - } - .onEnded { value in - let newScale = zoomScale * value.magnification - withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) { - zoomScale = min(max(newScale, 1.0), 5.0) - if zoomScale <= 1.05 { - zoomScale = 1.0 - zoomOffset = .zero - } - currentScale = zoomScale + .onEnded { value in + let newScale = zoomScale * value.magnification + withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) { + zoomScale = min(max(newScale, 1.0), 5.0) + if zoomScale <= 1.05 { + zoomScale = 1.0 + zoomOffset = .zero } + currentScale = zoomScale } - ) - // Pan when zoomed - .simultaneousGesture( - zoomScale > 1.05 ? - DragGesture() - .onChanged { value in - zoomOffset = value.translation - } - .onEnded { _ in - // Clamp offset - } - : nil - ) + } + ) + .simultaneousGesture( + zoomScale > 1.05 ? + DragGesture() + .onChanged { value in zoomOffset = value.translation } + .onEnded { _ in } + : nil + ) .task { if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachmentId) { image = cached @@ -114,6 +106,21 @@ struct ZoomableImagePage: View { } } + // MARK: - Aspect-fit calculation (Telegram parity) + + /// Calculates the image frame to fill the view while maintaining aspect ratio. + /// Telegram: `contentSize.fitted(boundsSize)` in ZoomableContentGalleryItemNode. + private func fittedSize(_ imageSize: CGSize, in viewSize: CGSize) -> CGSize { + guard imageSize.width > 0, imageSize.height > 0, + viewSize.width > 0, viewSize.height > 0 else { + return viewSize + } + let scale = min(viewSize.width / imageSize.width, + viewSize.height / imageSize.height) + return CGSize(width: imageSize.width * scale, + height: imageSize.height * scale) + } + // MARK: - Placeholder private var placeholder: some View { @@ -126,4 +133,3 @@ struct ZoomableImagePage: View { } } } - diff --git a/Rosetta/Features/Chats/ChatList/ChatListView.swift b/Rosetta/Features/Chats/ChatList/ChatListView.swift index 66224f4..696d80f 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListView.swift @@ -135,10 +135,19 @@ struct ChatListView: View { } .onReceive(NotificationCenter.default.publisher(for: .openChatFromNotification)) { notification in guard let route = notification.object as? ChatRoute else { return } - // Navigate to the chat from push notification tap (fast path) - navigationState.path = [route] AppDelegate.pendingChatRoute = nil AppDelegate.pendingChatRouteTimestamp = nil + // If already inside a chat, pop first then push after animation. + // Direct path replacement reuses the same ChatDetailView (SwiftUI optimization), + // which only updates the toolbar but keeps the old messages. + if !navigationState.path.isEmpty { + navigationState.path = [] + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + navigationState.path = [route] + } + } else { + navigationState.path = [route] + } } .onAppear { // Cold start fallback: ChatListView didn't exist when notification was posted.