diff --git a/Rosetta/Core/Layout/MessageCellLayout.swift b/Rosetta/Core/Layout/MessageCellLayout.swift index a8150e2..1d8d659 100644 --- a/Rosetta/Core/Layout/MessageCellLayout.swift +++ b/Rosetta/Core/Layout/MessageCellLayout.swift @@ -203,7 +203,7 @@ extension MessageCellLayout { } else { messageType = .text } - // Emoji-only: 1-3 emoji without other text → large font, no bubble + // Emoji-only: single emoji without other text → large font, no bubble if messageType == .text && !config.text.isEmpty && EmojiParser.isEmojiOnly(config.text) { messageType = .emojiOnly } diff --git a/Rosetta/Core/Services/CallManager+Runtime.swift b/Rosetta/Core/Services/CallManager+Runtime.swift index 4b613e9..364b91a 100644 --- a/Rosetta/Core/Services/CallManager+Runtime.swift +++ b/Rosetta/Core/Services/CallManager+Runtime.swift @@ -277,6 +277,9 @@ extension CallManager { didReceiveCallKitDeactivation = false var finalState = CallUiState() + finalState.peerPublicKey = uiState.peerPublicKey + finalState.peerTitle = uiState.peerTitle + finalState.peerUsername = uiState.peerUsername if let reason, !reason.isEmpty { finalState.statusText = reason } diff --git a/Rosetta/Core/Utils/EmojiParser.swift b/Rosetta/Core/Utils/EmojiParser.swift index b6d2686..54bf7fb 100644 --- a/Rosetta/Core/Utils/EmojiParser.swift +++ b/Rosetta/Core/Utils/EmojiParser.swift @@ -68,15 +68,14 @@ enum EmojiParser { return ":emoji_\(codes.joined(separator: "-")):" } - // MARK: - Emoji-Only Detection (Telegram Parity) + // MARK: - Emoji-Only Detection - /// Returns true if text contains ONLY emoji characters (no text, no limit on count). - /// Telegram parity: `ChatMessageItemCommon.swift:243-252` — no max count, - /// just `message.text.containsOnlyEmoji` (EmojiUtils.swift:77-78). + /// Returns true if text is exactly ONE emoji character (enlarged display). + /// Multiple emoji use regular text size. static func isEmojiOnly(_ text: String) -> Bool { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return false } - return trimmed.allSatisfy { $0.isEmojiCharacter } + guard trimmed.count == 1 else { return false } + return trimmed.first!.isEmojiCharacter } } diff --git a/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift b/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift index 1177979..55a5e43 100644 --- a/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift +++ b/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift @@ -162,10 +162,6 @@ struct ImageGalleryViewer: View { state.images.indices.contains(currentPage) ? state.images[currentPage] : nil } - private var heroAnimation: Animation { - .interpolatingSpring(duration: 0.3, bounce: 0, initialVelocity: 0) - } - private var backgroundOpacity: CGFloat { let progress = min(abs(panCoordinator.dragOffset.height) / 80, 1) return isExpanded ? max(1 - progress, 0) : 0 @@ -184,13 +180,8 @@ struct ImageGalleryViewer: View { // MARK: - Body var body: some View { - let sf = state.sourceFrame - TabView(selection: $currentPage) { ForEach(Array(state.images.enumerated()), id: \.element.attachmentId) { index, info in - let isHeroPage = index == state.initialIndex - let heroActive = isHeroPage && !isExpanded - ZoomableImagePage( attachmentId: info.attachmentId, onDismiss: { dismissAction() }, @@ -198,14 +189,7 @@ struct ImageGalleryViewer: View { 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 - )) + .offset(panCoordinator.dragOffset) .tag(index) } } @@ -222,7 +206,7 @@ struct ImageGalleryViewer: View { .task { prefetchAdjacentImages(around: state.initialIndex) guard !isExpanded else { return } - withAnimation(heroAnimation) { isExpanded = true } + withAnimation(.easeOut(duration: 0.2)) { isExpanded = true } } .onChange(of: currentPage) { _, p in prefetchAdjacentImages(around: p) } .onChange(of: currentZoomScale) { _, s in panCoordinator.isEnabled = s <= 1.05 } @@ -237,7 +221,7 @@ struct ImageGalleryViewer: View { if y > 50 || v > 1000 { dismissAction() } else { - withAnimation(heroAnimation.speed(1.2)) { + withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { panCoordinator.dragOffset = .zero } } @@ -373,25 +357,7 @@ struct ImageGalleryViewer: View { // MARK: - Dismiss private func dismissAction() { - if currentZoomScale > 1.05 || currentPage != state.initialIndex { - fadeDismiss() - } else { - heroDismiss() - } - } - - private func heroDismiss() { - guard !isDismissing else { return } - isDismissing = true - panCoordinator.isEnabled = false - Task { - withAnimation(heroAnimation.speed(1.2)) { - panCoordinator.dragOffset = .zero - isExpanded = false - } - try? await Task.sleep(for: .seconds(0.35)) - onDismiss() - } + fadeDismiss() } private func fadeDismiss() { @@ -448,30 +414,3 @@ struct ImageGalleryViewer: View { } } -// MARK: - GalleryPageModifier - -/// Applies hero transition frame/offset for the initial page. -/// Uses a SINGLE view branch (no if/else) to preserve SwiftUI structural identity -/// across the hero → expanded transition. This prevents GeometryReader inside -/// ZoomableImagePage from receiving stale/incorrect proposed sizes during the swap. -private struct GalleryPageModifier: ViewModifier { - let heroActive: Bool - let sourceFrame: CGRect - let fullSize: CGSize - let dragOffset: CGSize - - func body(content: Content) -> some View { - content - .frame( - width: heroActive ? sourceFrame.width : fullSize.width, - height: heroActive ? sourceFrame.height : fullSize.height - ) - .clipped() - .offset( - x: heroActive ? sourceFrame.minX : 0, - y: heroActive ? sourceFrame.minY : 0 - ) - .offset(dragOffset) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } -} diff --git a/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift b/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift index 52562c1..1d869c2 100644 --- a/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift +++ b/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift @@ -4,8 +4,9 @@ import UIKit // MARK: - ZoomableImagePage /// 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. +/// Uses screen bounds for image sizing (Telegram parity) — GeometryReader is unreliable +/// inside TabView `.page` style, especially during hero animation transitions. +/// GeometryReader is kept only for centering via `.position()`. struct ZoomableImagePage: View { let attachmentId: String @@ -20,14 +21,20 @@ struct ZoomableImagePage: View { @State private var zoomOffset: CGSize = .zero @GestureState private var pinchScale: CGFloat = 1.0 + /// Fixed screen bounds for fittedSize calculation — not dependent on GeometryReader. + /// Telegram uses the same approach: sizes are computed against screen dimensions, + /// UIScrollView handles the rest. + private let screenSize = UIScreen.main.bounds.size + var body: some View { let effectiveScale = zoomScale * pinchScale GeometryReader { geo in - let viewSize = geo.size + let centerX = geo.size.width / 2 + let centerY = geo.size.height / 2 if let image { - let fitted = fittedSize(image.size, in: viewSize) + let fitted = fittedSize(image.size, in: screenSize) Image(uiImage: image) .resizable() @@ -37,13 +44,12 @@ struct ZoomableImagePage: View { x: effectiveScale > 1.05 ? zoomOffset.width : 0, y: effectiveScale > 1.05 ? zoomOffset.height : 0 ) - .position(x: viewSize.width / 2, y: viewSize.height / 2) + .position(x: centerX, y: centerY) } else { placeholder - .position(x: viewSize.width / 2, y: viewSize.height / 2) + .position(x: centerX, y: centerY) } } - .ignoresSafeArea() .contentShape(Rectangle()) .onTapGesture(count: 2) { withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {