diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index a94fbeb..ed7084e 100644 --- a/Rosetta.xcodeproj/project.pbxproj +++ b/Rosetta.xcodeproj/project.pbxproj @@ -25,11 +25,9 @@ F1A000012F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000022F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport */; }; F1A000032F6F00010092AD05 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000042F6F00010092AD05 /* FirebaseMessaging */; }; F1A000062F6F00010092AD05 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000072F6F00010092AD05 /* FirebaseCrashlytics */; }; - - LA00000102F8D22220092AD05 /* RosettaLiveActivityWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = LA000000D2F8D22220092AD05 /* RosettaLiveActivityWidgetBundle.swift */; }; + LA00000092F8D22220092AD05 /* RosettaLiveActivityWidget.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = LA00000022F8D22220092AD05 /* RosettaLiveActivityWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; LA00000112F8D22220092AD05 /* CallLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = LA000000E2F8D22220092AD05 /* CallLiveActivity.swift */; }; LA00000122F8D22220092AD05 /* CallActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = LA000000F2F8D22220092AD05 /* CallActivityAttributes.swift */; }; - LA00000092F8D22220092AD05 /* RosettaLiveActivityWidget.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = LA00000022F8D22220092AD05 /* RosettaLiveActivityWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -47,7 +45,6 @@ remoteGlobalIDString = 853F29612F4B50410092AD05; remoteInfo = Rosetta; }; - LA000000B2F8D22220092AD05 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 853F295A2F4B50410092AD05 /* Project object */; @@ -58,18 +55,6 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ - - LA000000Z2F8D22220092AD05 /* Embed App Extensions */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 13; - files = ( - LA00000092F8D22220092AD05 /* RosettaLiveActivityWidget.appex in Embed App Extensions */, - ); - name = "Embed App Extensions"; - runOnlyForDeploymentPostprocessing = 0; - }; 249D2C5CD23DB96B22202215 /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -92,6 +77,17 @@ name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; + LA000000Z2F8D22220092AD05 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + LA00000092F8D22220092AD05 /* RosettaLiveActivityWidget.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -108,9 +104,7 @@ C9FC5C4F7E26FAFEC47C1D51 /* BehaviorParityFixtureTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BehaviorParityFixtureTests.swift; sourceTree = ""; }; DBAA4AD95B61886B5A22EF0D /* MigrationHarnessTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MigrationHarnessTests.swift; sourceTree = ""; }; E20000042F8D11110092AD05 /* WebRTC.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = WebRTC.xcframework; path = Frameworks/WebRTC.xcframework; sourceTree = ""; }; - LA00000022F8D22220092AD05 /* RosettaLiveActivityWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = RosettaLiveActivityWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; - LA000000D2F8D22220092AD05 /* RosettaLiveActivityWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RosettaLiveActivityWidgetBundle.swift; sourceTree = ""; }; LA000000E2F8D22220092AD05 /* CallLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallLiveActivity.swift; sourceTree = ""; }; LA000000F2F8D22220092AD05 /* CallActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallActivityAttributes.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -154,7 +148,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - LA00000042F8D22220092AD05 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -227,7 +220,6 @@ path = RosettaNotificationService; sourceTree = ""; }; - LA000000C2F8D22220092AD05 /* RosettaLiveActivityWidget */ = { isa = PBXGroup; children = ( @@ -273,7 +265,7 @@ ); dependencies = ( 3323872B02212359E2291EE8 /* PBXTargetDependency */, - LA000000A2F8D22220092AD05 /* PBXTargetDependency */, + LA000000A2F8D22220092AD05 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 853F29642F4B50410092AD05 /* Rosetta */, @@ -308,7 +300,6 @@ productReference = A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */; productType = "com.apple.product-type.app-extension"; }; - LA00000012F8D22220092AD05 /* RosettaLiveActivityWidget */ = { isa = PBXNativeTarget; buildConfigurationList = LA00000062F8D22220092AD05 /* Build configuration list for PBXNativeTarget "RosettaLiveActivityWidget" */; @@ -369,7 +360,7 @@ targets = ( 853F29612F4B50410092AD05 /* Rosetta */, E47730762E9823BA2D02A197 /* RosettaNotificationService */, - LA00000012F8D22220092AD05 /* RosettaLiveActivityWidget */, + LA00000012F8D22220092AD05 /* RosettaLiveActivityWidget */, 219188CF4FCBF8E8CF11BEC2 /* RosettaTests */, ); }; @@ -397,7 +388,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - LA00000052F8D22220092AD05 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -436,12 +426,11 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - LA00000032F8D22220092AD05 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - LA00000112F8D22220092AD05 /* CallLiveActivity.swift in Sources */, + LA00000112F8D22220092AD05 /* CallLiveActivity.swift in Sources */, LA00000122F8D22220092AD05 /* CallActivityAttributes.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -461,7 +450,6 @@ target = 853F29612F4B50410092AD05 /* Rosetta */; targetProxy = D1E9D598009C8306B116CA87 /* PBXContainerItemProxy */; }; - LA000000A2F8D22220092AD05 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = LA00000012F8D22220092AD05 /* RosettaLiveActivityWidget */; @@ -761,7 +749,6 @@ }; name = Release; }; - LA00000072F8D22220092AD05 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -855,7 +842,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; - LA00000062F8D22220092AD05 /* Build configuration list for PBXNativeTarget "RosettaLiveActivityWidget" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Rosetta/Core/Services/CallManager+Runtime.swift b/Rosetta/Core/Services/CallManager+Runtime.swift index cc42543..1ec4da5 100644 --- a/Rosetta/Core/Services/CallManager+Runtime.swift +++ b/Rosetta/Core/Services/CallManager+Runtime.swift @@ -1,6 +1,7 @@ import AVFAudio import CryptoKit import Foundation +import SwiftUI import UIKit import WebRTC @@ -148,9 +149,12 @@ extension CallManager { remoteDescriptionSet = false lastPeerSharedPublicHex = "" - uiState = CallUiState() + var finalState = CallUiState() if let reason, !reason.isEmpty { - uiState.statusText = reason + finalState.statusText = reason + } + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { + uiState = finalState } deactivateAudioSession() diff --git a/Rosetta/Core/Services/CallManager.swift b/Rosetta/Core/Services/CallManager.swift index 2573095..a7758e2 100644 --- a/Rosetta/Core/Services/CallManager.swift +++ b/Rosetta/Core/Services/CallManager.swift @@ -3,6 +3,7 @@ import AVFAudio import Combine import CryptoKit import Foundation +import SwiftUI import WebRTC @MainActor @@ -143,6 +144,20 @@ final class CallManager: NSObject, ObservableObject { print("[Call] toggleSpeaker: isSpeakerOn=\(nextSpeaker), outputs=\(route.outputs.map { $0.portName })") } + func minimizeCall() { + guard uiState.isVisible else { return } + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { + uiState.isMinimized = true + } + } + + func expandCall() { + guard uiState.isVisible else { return } + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { + uiState.isMinimized = false + } + } + // MARK: - Protocol handlers private func wireProtocolHandlers() { @@ -199,6 +214,9 @@ final class CallManager: NSObject, ObservableObject { beginCallSession(peerPublicKey: incomingPeer, title: "", username: "") role = .callee uiState.phase = .incoming + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { + uiState.isMinimized = false + } uiState.statusText = "Incoming call..." hydratePeerIdentity(for: incomingPeer) CallSoundManager.shared.playRingtone() diff --git a/Rosetta/Core/Services/CallModels.swift b/Rosetta/Core/Services/CallModels.swift index 54bdb7d..cd35ad7 100644 --- a/Rosetta/Core/Services/CallModels.swift +++ b/Rosetta/Core/Services/CallModels.swift @@ -33,11 +33,17 @@ struct CallUiState: Equatable, Sendable { var isMuted: Bool = false var isSpeakerOn: Bool = false var keyCast: String = "" + var isMinimized: Bool = false var isVisible: Bool { phase != .idle } + /// Full-screen overlay should show when call is active AND not minimized. + var isFullScreenVisible: Bool { + isVisible && !isMinimized + } + var displayName: String { if !peerTitle.isEmpty { return peerTitle } if !peerUsername.isEmpty { return "@\(peerUsername)" } diff --git a/Rosetta/DesignSystem/Components/CallBarSafeAreaBridge.swift b/Rosetta/DesignSystem/Components/CallBarSafeAreaBridge.swift new file mode 100644 index 0000000..4253865 --- /dev/null +++ b/Rosetta/DesignSystem/Components/CallBarSafeAreaBridge.swift @@ -0,0 +1,69 @@ +import SwiftUI + +// MARK: - Call Bar Safe Area Bridge + +/// UIKit bridge that pushes NavigationStack's nav bar down by setting +/// `additionalSafeAreaInsets.top` on every UINavigationController in the window. +/// +/// `UIViewController.navigationController` walks UP the parent chain — but the +/// bridge VC is a SIBLING of NavigationStack's UINavigationController, not a child. +/// So we walk DOWN from the window's root VC to find all navigation controllers. +struct CallBarSafeAreaBridge: UIViewRepresentable { + let topInset: CGFloat + + func makeUIView(context: Context) -> CallBarInsetView { + let view = CallBarInsetView() + view.isHidden = true + view.isUserInteractionEnabled = false + return view + } + + func updateUIView(_ view: CallBarInsetView, context: Context) { + view.topInset = topInset + view.applyInset() + } +} + +/// Invisible UIView that finds all UINavigationControllers in the window +/// and sets `additionalSafeAreaInsets.top` on each. +final class CallBarInsetView: UIView { + var topInset: CGFloat = 0 + private var lastAppliedInset: CGFloat = -1 + + override func didMoveToWindow() { + super.didMoveToWindow() + lastAppliedInset = -1 // Force re-apply + applyInset() + } + + func applyInset() { + guard topInset != lastAppliedInset else { return } + guard let window, let rootVC = window.rootViewController else { return } + Self.apply(inset: topInset, in: rootVC) + lastAppliedInset = topInset + } + + /// Recursively walk child view controllers (NOT presentedViewController) + /// and set additionalSafeAreaInsets.top on every UINavigationController. + private static func apply(inset: CGFloat, in vc: UIViewController) { + if let nav = vc as? UINavigationController, + nav.additionalSafeAreaInsets.top != inset { + nav.additionalSafeAreaInsets.top = inset + } + for child in vc.children { + apply(inset: inset, in: child) + } + } +} + +extension View { + /// Push NavigationStack's nav bar down by `inset` points. + /// Uses UIKit `additionalSafeAreaInsets` — the only reliable mechanism. + func callBarSafeAreaInset(_ inset: CGFloat) -> some View { + background( + CallBarSafeAreaBridge(topInset: inset) + .frame(width: 0, height: 0) + .allowsHitTesting(false) + ) + } +} diff --git a/Rosetta/DesignSystem/Components/CallGradientBackground.swift b/Rosetta/DesignSystem/Components/CallGradientBackground.swift new file mode 100644 index 0000000..5cca330 --- /dev/null +++ b/Rosetta/DesignSystem/Components/CallGradientBackground.swift @@ -0,0 +1,124 @@ +import SwiftUI + +// MARK: - Animated Call Gradient Background + +/// Full-screen animated gradient tied to the peer's avatar color. +/// Uses Canvas + TimelineView for GPU-composited rendering at 30fps. +struct CallGradientBackground: View { + let colorIndex: Int + private let palette: CallGradientPalette + private let baseColor = Color(hex: 0x0D0D14) + + init(colorIndex: Int) { + self.colorIndex = colorIndex + self.palette = CallGradientPalette(colorIndex: colorIndex) + } + + var body: some View { + TimelineView(.periodic(from: .now, by: 1.0 / 30.0)) { timeline in + let t = timeline.date.timeIntervalSince1970 + Canvas { context, size in + // Solid dark base + context.fill( + Path(CGRect(origin: .zero, size: size)), + with: .color(baseColor) + ) + + let w = size.width + let h = size.height + + // 3 animated radial gradient blobs + drawBlob( + context: context, + center: CGPoint( + x: w * (0.5 + 0.3 * sin(t * 0.23)), + y: h * (0.3 + 0.2 * sin(t * 0.19 + 1.0)) + ), + radius: max(w, h) * 0.7, + color: palette.primary, + opacity: 0.55 + ) + + drawBlob( + context: context, + center: CGPoint( + x: w * (0.5 + 0.35 * sin(t * 0.17 + 2.0)), + y: h * (0.65 + 0.25 * sin(t * 0.21 + 3.5)) + ), + radius: max(w, h) * 0.65, + color: palette.shifted, + opacity: 0.45 + ) + + drawBlob( + context: context, + center: CGPoint( + x: w * (0.5 + 0.2 * sin(t * 0.29 + 4.0)), + y: h * (0.45 + 0.25 * sin(t * 0.15 + 5.5)) + ), + radius: max(w, h) * 0.6, + color: palette.dark, + opacity: 0.5 + ) + } + } + .ignoresSafeArea() + } + + private func drawBlob( + context: GraphicsContext, + center: CGPoint, + radius: CGFloat, + color: Color, + opacity: Double + ) { + let rect = CGRect( + x: center.x - radius, + y: center.y - radius, + width: radius * 2, + height: radius * 2 + ) + let gradient = Gradient(colors: [ + color.opacity(opacity), + color.opacity(opacity * 0.4), + .clear + ]) + context.fill( + Path(ellipseIn: rect), + with: .radialGradient( + gradient, + center: center, + startRadius: 0, + endRadius: radius + ) + ) + } +} + +// MARK: - Gradient Palette + +/// Derives 3 colors from the peer's avatar color index. +private struct CallGradientPalette { + let primary: Color + let dark: Color + let shifted: Color + + init(colorIndex: Int) { + let colors = RosettaColors.avatarColors + let idx = abs(colorIndex) % colors.count + let tint = colors[idx].tint + + let uiColor = UIColor(tint) + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + uiColor.getRed(&r, green: &g, blue: &b, alpha: &a) + + primary = tint + dark = Color(red: r * 0.27, green: g * 0.27, blue: b * 0.27) + + // Hue-shift +25 degrees, darken to 70% + var h: CGFloat = 0, s: CGFloat = 0, br: CGFloat = 0 + uiColor.getHue(&h, saturation: &s, brightness: &br, alpha: &a) + let shiftedHue = (h + 25.0 / 360.0).truncatingRemainder(dividingBy: 1.0) + shifted = Color(hue: shiftedHue, saturation: s, brightness: br * 0.7) + } +} diff --git a/Rosetta/DesignSystem/Components/CallPulsingRings.swift b/Rosetta/DesignSystem/Components/CallPulsingRings.swift new file mode 100644 index 0000000..9139edb --- /dev/null +++ b/Rosetta/DesignSystem/Components/CallPulsingRings.swift @@ -0,0 +1,57 @@ +import SwiftUI + +// MARK: - Staggered Ripple Animation + +/// Telegram-style staggered ripple rings that emanate from the avatar. +/// 4 rings, each delayed by 0.6s, creating a sound-wave effect. +struct CallPulsingRings: View { + let size: CGFloat + var color: Color = .white + + private let ringCount = 4 + private let ringDuration: Double = 2.4 + private let ringDelay: Double = 0.6 + private let maxScale: CGFloat = 1.8 + + var body: some View { + ZStack { + ForEach(0.. Void - ) -> some View { - Button(action: action) { - VStack(spacing: 8) { - Image(systemName: icon) - .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(.white) - .frame(width: 52, height: 52) - .background(Circle().fill(color)) - Text(title) - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.white.opacity(0.92)) + // MARK: - Status + + private var statusSection: some View { + Group { + if state.phase == .active { + Text(durationText) + .font(.system(size: 15, weight: .regular).monospacedDigit()) + .contentTransition(.numericText()) + } else { + Text(statusText(for: state.phase)) + .font(.system(size: 15, weight: .regular)) } } - .buttonStyle(.plain) + .foregroundStyle(Color.white.opacity(0.7)) } + // MARK: - Swipe-to-Minimize Gesture + + private var minimizeGesture: some Gesture { + DragGesture(minimumDistance: 30) + .onChanged { value in + let dy = value.translation.height + // Only allow downward swipe + if dy > 0 { + dragOffset = dy + } + } + .onEnded { value in + let dy = value.translation.height + let velocity = value.predictedEndTranslation.height + + // Minimize if dragged > 150pt or velocity > 300pt/s + if dy > 150 || velocity > 300 { + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { + dragOffset = UIScreen.main.bounds.height + } + Task { @MainActor in + try? await Task.sleep(for: .seconds(0.4)) + callManager.minimizeCall() + dragOffset = 0 + } + } else { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + dragOffset = 0 + } + } + } + } + + // MARK: - Status Text + private func statusText(for phase: CallPhase) -> String { switch phase { case .incoming: return "Incoming call" - case .outgoing: return "Calling..." + case .outgoing: return "Ringing..." case .keyExchange: return "Exchanging keys..." case .webRtcExchange: return "Connecting..." case .active: return "Active" @@ -160,23 +195,3 @@ struct ActiveCallOverlayView: View { } } } - -private struct PulsingRings: View { - @State private var animate = false - - var body: some View { - ZStack { - ForEach(0..<3, id: \.self) { index in - Circle() - .stroke(Color.white.opacity(0.08 - Double(index) * 0.02), lineWidth: 1.5) - .scaleEffect(animate ? 1.0 + CGFloat(index + 1) * 0.12 : 1.0) - .opacity(animate ? 0.0 : 0.6) - } - } - .onAppear { - withAnimation(.easeInOut(duration: 3.0).repeatForever(autoreverses: false)) { - animate = true - } - } - } -} diff --git a/Rosetta/Features/Calls/CallActionButtons.swift b/Rosetta/Features/Calls/CallActionButtons.swift new file mode 100644 index 0000000..915430c --- /dev/null +++ b/Rosetta/Features/Calls/CallActionButtons.swift @@ -0,0 +1,147 @@ +import SwiftUI + +// MARK: - Call Action Buttons + +/// Telegram-style call control buttons. Adapts layout based on call phase. +struct CallActionButtonsView: View { + @ObservedObject var callManager: CallManager + + private var state: CallUiState { + callManager.uiState + } + + var body: some View { + if state.phase == .incoming { + incomingButtons + } else { + activeButtons + } + } + + // MARK: - Incoming: Decline + Accept + + private var incomingButtons: some View { + HStack { + callButton( + title: "Decline", + icon: "phone.down.fill", + background: RosettaColors.error, + foreground: .white + ) { + callManager.declineIncomingCall() + } + + Spacer() + + callButton( + title: "Accept", + icon: "phone.fill", + background: RosettaColors.success, + foreground: .white, + pulse: true + ) { + _ = callManager.acceptIncomingCall() + } + } + .padding(.horizontal, 48) + } + + // MARK: - Active: Mute + Speaker + End + + private var activeButtons: some View { + HStack(spacing: 36) { + callButton( + title: state.isMuted ? "Unmute" : "Mute", + icon: state.isMuted ? "mic.slash.fill" : "mic.fill", + background: state.isMuted ? .white : Color.white.opacity(0.12), + foreground: state.isMuted ? .black : .white + ) { + callManager.toggleMute() + } + + callButton( + title: state.isSpeakerOn ? "Earpiece" : "Speaker", + icon: state.isSpeakerOn ? "speaker.wave.2.fill" : "speaker.slash.fill", + background: state.isSpeakerOn ? .white : Color.white.opacity(0.12), + foreground: state.isSpeakerOn ? .black : .white + ) { + callManager.toggleSpeaker() + } + + callButton( + title: "End", + icon: "phone.down.fill", + background: RosettaColors.error, + foreground: .white + ) { + callManager.endCall() + } + } + } + + // MARK: - Button Component + + @ViewBuilder + private func callButton( + title: String, + icon: String, + background: Color, + foreground: Color, + pulse: Bool = false, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + VStack(spacing: 8) { + ZStack { + Circle() + .fill(background) + .frame(width: 56, height: 56) + + Image(systemName: icon) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(foreground) + } + .modifier(PulseModifier(isActive: pulse)) + + Text(title) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white.opacity(0.92)) + } + } + .buttonStyle(CallButtonStyle()) + } +} + +// MARK: - Press Animation Style + +private struct CallButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.9 : 1.0) + .animation(.spring(response: 0.2, dampingFraction: 0.6), value: configuration.isPressed) + } +} + +// MARK: - Accept Pulse Modifier + +private struct PulseModifier: ViewModifier { + let isActive: Bool + @State private var isPulsing = false + + func body(content: Content) -> some View { + content + .scaleEffect(isActive && isPulsing ? 1.06 : 1.0) + .animation( + isActive && isPulsing + ? .easeInOut(duration: 1.2).repeatForever(autoreverses: true) + : .default, + value: isPulsing + ) + .onAppear { + if isActive { isPulsing = true } + } + .onChange(of: isActive) { _, active in + isPulsing = active + } + } +} diff --git a/Rosetta/Features/Calls/MinimizedCallBar.swift b/Rosetta/Features/Calls/MinimizedCallBar.swift new file mode 100644 index 0000000..69926ab --- /dev/null +++ b/Rosetta/Features/Calls/MinimizedCallBar.swift @@ -0,0 +1,86 @@ +import SwiftUI + +// MARK: - Minimized Call Bar (Telegram-style) + +/// Telegram-style call status bar that sits at the very top of the screen, +/// extending into the safe area (status bar region). Content is centered +/// in the bottom 24pt of the bar. Tapping ANYWHERE on the gradient +/// (including the status bar area) expands back to full-screen. +struct MinimizedCallBar: View { + @ObservedObject var callManager: CallManager + + private var state: CallUiState { + callManager.uiState + } + + private var durationText: String { + let duration = max(state.durationSec, 0) + let minutes = duration / 60 + let seconds = duration % 60 + return String(format: "%d:%02d", minutes, seconds) + } + + // Telegram colors: green=speaking, blue=muted/active, gray=connecting + private var gradientColors: [Color] { + switch state.phase { + case .active: + if state.isMuted { + // Blue gradient (muted) + return [Color(hex: 0x007FFF), Color(hex: 0x00AFFE)] + } else { + // Green gradient (speaking) + return [Color(hex: 0x33C659), Color(hex: 0x00A0B9)] + } + case .incoming: + return [Color(hex: 0x33C659), Color(hex: 0x00A0B9)] + case .outgoing, .keyExchange, .webRtcExchange: + // Gray (connecting) + return [Color(hex: 0xB6B6BB), Color(hex: 0xB6B6BB)] + case .ended: + return [Color(hex: 0xEF436C), Color(hex: 0xC0508D)] + case .idle: + return [Color(hex: 0x007FFF), Color(hex: 0x00AFFE)] + } + } + + private var statusText: String { + switch state.phase { + case .active: return durationText + case .incoming: return "Tap to Answer" + case .outgoing: return "Ringing..." + case .keyExchange, .webRtcExchange: return "Connecting..." + case .ended: return "Ended" + case .idle: return "" + } + } + + var body: some View { + // Content stays BELOW Dynamic Island. Only gradient extends behind it. + HStack(spacing: 5) { + Image(systemName: "phone.fill") + .font(.system(size: 12, weight: .bold)) + + Text(state.displayName) + .font(.system(size: 13, weight: .semibold)) + .lineLimit(1) + + Text(statusText) + .font(.system(size: 13, weight: .regular).monospacedDigit()) + } + .foregroundStyle(.white) + .frame(height: 36) + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + .onTapGesture { + callManager.expandCall() + } + .background( + LinearGradient( + colors: gradientColors, + startPoint: .leading, + endPoint: .trailing + ) + .ignoresSafeArea(edges: .top) // Only gradient extends behind Dynamic Island + ) + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/BubbleContextMenuOverlay.swift b/Rosetta/Features/Chats/ChatDetail/BubbleContextMenuOverlay.swift index e05938d..1bc9e19 100644 --- a/Rosetta/Features/Chats/ChatDetail/BubbleContextMenuOverlay.swift +++ b/Rosetta/Features/Chats/ChatDetail/BubbleContextMenuOverlay.swift @@ -1,28 +1,15 @@ import SwiftUI import UIKit -/// Action model for context menu buttons. -struct BubbleContextAction { - let title: String - let image: UIImage? - let role: UIMenuElement.Attributes - let handler: () -> Void -} - -/// Transparent overlay that attaches UIContextMenuInteraction to a message bubble. +/// Transparent overlay that triggers a Telegram-style context menu on long press. /// -/// Uses a **window snapshot** approach instead of UIHostingController preview: -/// 1. On long-press, captures a pixel-perfect screenshot of the bubble from the window -/// 2. Uses this snapshot as `UITargetedPreview` with `previewProvider: nil` -/// 3. UIKit lifts the snapshot in-place — no horizontal shift, no re-rendering issues -/// -/// Also supports an optional `onTap` callback that fires on single tap. -/// This is needed because the overlay UIView intercepts all touch events, -/// preventing SwiftUI `onTapGesture` on content below from firing. +/// Also supports single-tap routing (image viewer, file download, reply quote tap) +/// because the overlay UIView intercepts all touch events, preventing SwiftUI +/// `onTapGesture` on content below from firing. struct BubbleContextMenuOverlay: UIViewRepresentable { - let actions: [BubbleContextAction] + let items: [TelegramContextMenuItem] let previewShape: MessageBubbleShape - let readStatusText: String? + let isOutgoing: Bool /// Called when user single-taps the bubble. Receives tap location in the overlay's /// coordinate space (for determining which sub-element was tapped, e.g., which photo in a collage). @@ -38,48 +25,54 @@ struct BubbleContextMenuOverlay: UIViewRepresentable { func makeUIView(context: Context) -> UIView { let view = UIView() view.backgroundColor = .clear - let interaction = UIContextMenuInteraction(delegate: context.coordinator) - view.addInteraction(interaction) - // Single tap recognizer — coexists with context menu's long press. + // Single tap recognizer — coexists with long press. // ALL taps go through this (overlay UIView blocks SwiftUI gestures below). - // onTap handler in ChatDetailView routes to image viewer, file share, - // or posts .triggerAttachmentDownload notification for downloads. let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:))) view.addGestureRecognizer(tap) + // Long press → Telegram context menu + let longPress = UILongPressGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.handleLongPress(_:)) + ) + longPress.minimumPressDuration = 0.35 + view.addGestureRecognizer(longPress) + return view } func updateUIView(_ uiView: UIView, context: Context) { - // PERF: only update callbacks (lightweight pointer swap). - // Skip actions/previewShape/readStatusText — these involve array allocation - // and struct copying on EVERY layout pass (40× cells × 8 keyboard ticks = 320/s). - // Context menu will use stale actions until cell is recycled — acceptable trade-off. + context.coordinator.items = items + context.coordinator.previewShape = previewShape + context.coordinator.isOutgoing = isOutgoing context.coordinator.onTap = onTap + context.coordinator.replyQuoteHeight = replyQuoteHeight context.coordinator.onReplyQuoteTap = onReplyQuoteTap } func makeCoordinator() -> Coordinator { Coordinator(overlay: self) } - final class Coordinator: NSObject, UIContextMenuInteractionDelegate { - var actions: [BubbleContextAction] + final class Coordinator: NSObject { + var items: [TelegramContextMenuItem] var previewShape: MessageBubbleShape - var readStatusText: String? + var isOutgoing: Bool var onTap: ((CGPoint) -> Void)? var replyQuoteHeight: CGFloat = 0 var onReplyQuoteTap: (() -> Void)? - private var snapshotView: UIImageView? + private let haptic = UIImpactFeedbackGenerator(style: .medium) init(overlay: BubbleContextMenuOverlay) { - self.actions = overlay.actions + self.items = overlay.items self.previewShape = overlay.previewShape - self.readStatusText = overlay.readStatusText + self.isOutgoing = overlay.isOutgoing self.onTap = overlay.onTap self.replyQuoteHeight = overlay.replyQuoteHeight self.onReplyQuoteTap = overlay.onReplyQuoteTap } + // MARK: - Single Tap + @objc func handleTap(_ recognizer: UITapGestureRecognizer) { // Route taps in the reply quote region to the reply handler. if replyQuoteHeight > 0, let view = recognizer.view { @@ -93,100 +86,32 @@ struct BubbleContextMenuOverlay: UIViewRepresentable { onTap?(location) } - func contextMenuInteraction( - _ interaction: UIContextMenuInteraction, - configurationForMenuAtLocation location: CGPoint - ) -> UIContextMenuConfiguration? { - captureSnapshot(for: interaction) + // MARK: - Long Press → Context Menu - return UIContextMenuConfiguration( - identifier: nil, - previewProvider: nil, - actionProvider: { [weak self] _ in - self?.buildMenu() - } + @objc func handleLongPress(_ recognizer: UILongPressGestureRecognizer) { + guard recognizer.state == .began else { return } + haptic.impactOccurred() + presentMenu(from: recognizer.view) + } + + private func presentMenu(from view: UIView?) { + guard let view else { return } + guard !items.isEmpty else { return } + + // Capture snapshot from window + guard let (snapshot, frame) = TelegramContextMenuController.captureSnapshot(of: view) else { return } + + // Build bubble mask path + let shapePath = previewShape.path(in: CGRect(origin: .zero, size: frame.size)) + let bubblePath = UIBezierPath(cgPath: shapePath.cgPath) + + TelegramContextMenuController.present( + snapshot: snapshot, + sourceFrame: frame, + bubblePath: bubblePath, + items: items, + isOutgoing: isOutgoing ) } - - // MARK: - Snapshot - - private func captureSnapshot(for interaction: UIContextMenuInteraction) { - guard let view = interaction.view, let window = view.window else { return } - let frameInWindow = view.convert(view.bounds, to: window) - let renderer = UIGraphicsImageRenderer(size: view.bounds.size) - let image = renderer.image { ctx in - ctx.cgContext.translateBy(x: -frameInWindow.origin.x, y: -frameInWindow.origin.y) - window.drawHierarchy(in: window.bounds, afterScreenUpdates: false) - } - let sv = UIImageView(image: image) - sv.frame = view.bounds - view.addSubview(sv) - self.snapshotView = sv - } - - // MARK: - Menu - - private func buildMenu() -> UIMenu { - var sections: [UIMenuElement] = [] - - if let readStatus = readStatusText { - let readAction = UIAction( - title: readStatus, - image: UIImage(systemName: "checkmark"), - attributes: .disabled - ) { _ in } - sections.append(UIMenu(options: .displayInline, children: [readAction])) - } - - let menuActions = actions.map { action in - UIAction( - title: action.title, - image: action.image, - attributes: action.role - ) { _ in - action.handler() - } - } - sections.append(UIMenu(options: .displayInline, children: menuActions)) - - return UIMenu(children: sections) - } - - // MARK: - Targeted Preview (lift & dismiss) - - func contextMenuInteraction( - _ interaction: UIContextMenuInteraction, - previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration - ) -> UITargetedPreview? { - guard let sv = snapshotView else { return nil } - return makeTargetedPreview(for: sv) - } - - func contextMenuInteraction( - _ interaction: UIContextMenuInteraction, - previewForDismissingMenuWithConfiguration configuration: UIContextMenuConfiguration - ) -> UITargetedPreview? { - guard let sv = snapshotView else { return nil } - return makeTargetedPreview(for: sv) - } - - func contextMenuInteraction( - _ interaction: UIContextMenuInteraction, - willEndFor configuration: UIContextMenuConfiguration, - animator: (any UIContextMenuInteractionAnimating)? - ) { - animator?.addCompletion { [weak self] in - self?.snapshotView?.removeFromSuperview() - self?.snapshotView = nil - } - } - - private func makeTargetedPreview(for view: UIView) -> UITargetedPreview { - let params = UIPreviewParameters() - let shapePath = previewShape.path(in: view.bounds) - params.visiblePath = UIBezierPath(cgPath: shapePath.cgPath) - params.backgroundColor = .clear - return UITargetedPreview(view: view, parameters: params) - } } } diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index a226b61..98aaddc 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -1167,58 +1167,51 @@ private extension ChatDetailView { // MARK: - Reply Bar /// Extract reply preview text for ComposerView (same logic as SwiftUI replyBar). + /// + /// Priority: attachment type label first, then clean caption text. + /// Previous version checked `message.text` BEFORE attachment type which caused empty + /// previews when text contained invisible/encrypted characters for photo/file messages. func replyPreviewText(for message: ChatMessage) -> String { - if message.attachments.contains(where: { $0.type == .image }) { - let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines) - return caption.isEmpty ? "Photo" : caption - } - if let file = message.attachments.first(where: { $0.type == .file }) { - let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines) - if !caption.isEmpty { return caption } - let parsed = AttachmentPreviewCodec.parseFilePreview(file.preview) - if !parsed.fileName.isEmpty { return parsed.fileName } - return file.id.isEmpty ? "File" : file.id - } - if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" } - if message.attachments.contains(where: { $0.type == .messages }) { return "Forwarded message" } - // Android/Desktop parity: show "Call" for call attachments - if message.attachments.contains(where: { $0.type == .call }) { return "Call" } - let trimmed = message.text.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { return message.text } - if !message.attachments.isEmpty { return "Attachment" } + // 1. Determine attachment type label + let attachmentLabel: String? = { + for att in message.attachments { + switch att.type { + case .image: return "Photo" + case .file: + let parsed = AttachmentPreviewCodec.parseFilePreview(att.preview) + if !parsed.fileName.isEmpty { return parsed.fileName } + return att.id.isEmpty ? "File" : att.id + case .avatar: return "Avatar" + case .messages: return "Forwarded message" + case .call: return "Call" + } + } + return nil + }() + + // 2. Clean caption — strip invisible chars (zero-width spaces, encrypted residue) + let visibleText: String = { + let stripped = message.text + .trimmingCharacters(in: .whitespacesAndNewlines) + .filter { !$0.isASCII || $0.asciiValue! >= 0x20 } // drop control chars + // Extra guard: if text looks like encrypted payload, ignore it + if MessageCellLayout.isGarbageOrEncrypted(stripped) { return "" } + return stripped + }() + + // 3. For image/file with non-empty caption: show caption + if attachmentLabel != nil, !visibleText.isEmpty { return visibleText } + // 4. For image/file with no caption: show type label + if let label = attachmentLabel { return label } + // 5. No attachment: show text + if !visibleText.isEmpty { return message.text } return "" } @ViewBuilder func replyBar(for message: ChatMessage) -> some View { let senderName = senderDisplayName(for: message.fromPublicKey) - let previewText: String = { - // Attachment type labels — check BEFORE text so photo/avatar messages - // always show their type even if text contains invisible characters. - if message.attachments.contains(where: { $0.type == .image }) { - let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines) - return caption.isEmpty ? "Photo" : caption - } - if let file = message.attachments.first(where: { $0.type == .file }) { - let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines) - if !caption.isEmpty { return caption } - let parsed = AttachmentPreviewCodec.parseFilePreview(file.preview) - if !parsed.fileName.isEmpty { return parsed.fileName } - return file.id.isEmpty ? "File" : file.id - } - if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" } - if message.attachments.contains(where: { $0.type == .messages }) { return "Forwarded message" } - // Android/Desktop parity: show "Call" for call attachments - if message.attachments.contains(where: { $0.type == .call }) { return "Call" } - // No known attachment type — fall back to text - let trimmed = message.text.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { return message.text } - if !message.attachments.isEmpty { return "Attachment" } - return "" - }() - #if DEBUG - let _ = print("📋 REPLY: preview='\(previewText.prefix(30))' text='\(message.text.prefix(30))' textHex=\(Array(message.text.utf8).prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")) atts=\(message.attachments.count) types=\(message.attachments.map { $0.type.rawValue })") - #endif + let previewText = replyPreviewText(for: message) HStack(spacing: 0) { RoundedRectangle(cornerRadius: 1.0) diff --git a/Rosetta/Features/Chats/ChatDetail/ComposerView.swift b/Rosetta/Features/Chats/ChatDetail/ComposerView.swift index 4175967..38d5824 100644 --- a/Rosetta/Features/Chats/ChatDetail/ComposerView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ComposerView.swift @@ -235,12 +235,18 @@ final class ComposerView: UIView, UITextViewDelegate { func setReply(senderName: String?, previewText: String?) { let shouldShow = senderName != nil + + // Always update label text when reply is active (fixes race where + // syncComposerState runs before SwiftUI body re-evaluates with new state) + if shouldShow { + replySenderLabel.text = senderName + replyPreviewLabel.text = previewText ?? "" + } + guard shouldShow != isReplyVisible else { return } isReplyVisible = shouldShow if shouldShow { - replySenderLabel.text = senderName - replyPreviewLabel.text = previewText ?? "" replyBar.isHidden = false } diff --git a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift index ae726fb..206f941 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift @@ -122,9 +122,9 @@ struct MessageCellView: View, Equatable { .background { bubbleBackground(outgoing: outgoing, position: position) } .overlay { BubbleContextMenuOverlay( - actions: bubbleActions(for: message), + items: contextMenuItems(for: message), previewShape: MessageBubbleShape(position: position, outgoing: outgoing), - readStatusText: contextMenuReadStatus(for: message), + isOutgoing: outgoing, replyQuoteHeight: replyData != nil ? 46 : 0, onReplyQuoteTap: replyData.map { reply in { [reply] in actions.onScrollToMessage(reply.message_id) } @@ -262,9 +262,9 @@ struct MessageCellView: View, Equatable { .background { bubbleBackground(outgoing: outgoing, position: position) } .overlay { BubbleContextMenuOverlay( - actions: bubbleActions(for: message), + items: contextMenuItems(for: message), previewShape: MessageBubbleShape(position: position, outgoing: outgoing), - readStatusText: contextMenuReadStatus(for: message), + isOutgoing: outgoing, onTap: !imageAttachments.isEmpty ? { _ in if let firstId = imageAttachments.first?.id { actions.onImageTap(firstId) @@ -362,9 +362,9 @@ struct MessageCellView: View, Equatable { .clipShape(MessageBubbleShape(position: position, outgoing: outgoing)) .overlay { BubbleContextMenuOverlay( - actions: bubbleActions(for: message), + items: contextMenuItems(for: message), previewShape: MessageBubbleShape(position: position, outgoing: outgoing), - readStatusText: contextMenuReadStatus(for: message), + isOutgoing: outgoing, onTap: !attachments.isEmpty ? { tapLocation in if !imageAttachments.isEmpty { let tappedId = imageAttachments.count == 1 @@ -536,48 +536,13 @@ struct MessageCellView: View, Equatable { // MARK: - Context Menu - private func contextMenuReadStatus(for message: ChatMessage) -> String? { - let outgoing = message.isFromMe(myPublicKey: currentPublicKey) - guard outgoing, message.deliveryStatus == .delivered, message.isRead else { return nil } - return "Read" - } - - private func bubbleActions(for message: ChatMessage) -> [BubbleContextAction] { - var result: [BubbleContextAction] = [] - - // Desktop parity: system accounts + ATTACHMENTS_NOT_ALLOWED_TO_REPLY = [AVATAR, MESSAGES] - let isAvatarOrForwarded = message.attachments.contains(where: { $0.type == .avatar || $0.type == .messages }) - let canReplyForward = !isSavedMessages && !isSystemAccount && !isAvatarOrForwarded - - if canReplyForward { - result.append(BubbleContextAction( - title: "Reply", - image: UIImage(systemName: "arrowshape.turn.up.left"), - role: [] - ) { actions.onReply(message) }) - } - - result.append(BubbleContextAction( - title: "Copy", - image: UIImage(systemName: "doc.on.doc"), - role: [] - ) { actions.onCopy(message.text) }) - - if canReplyForward { - result.append(BubbleContextAction( - title: "Forward", - image: UIImage(systemName: "arrowshape.turn.up.right"), - role: [] - ) { actions.onForward(message) }) - } - - result.append(BubbleContextAction( - title: "Delete", - image: UIImage(systemName: "trash"), - role: .destructive - ) { actions.onDelete(message) }) - - return result + private func contextMenuItems(for message: ChatMessage) -> [TelegramContextMenuItem] { + TelegramContextMenuBuilder.menuItems( + for: message, + actions: actions, + isSavedMessages: isSavedMessages, + isSystemAccount: isSystemAccount + ) } // MARK: - Reply Quote diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index 9819a27..ae5b43a 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -9,7 +9,7 @@ import UIKit /// 3. No SwiftUI, no UIHostingConfiguration, no self-sizing /// /// Subviews are always present but hidden when not needed (no alloc/dealloc overhead). -final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDelegate { +final class NativeMessageCell: UICollectionViewCell { // MARK: - Constants @@ -400,9 +400,10 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel deliveryFailedButton.addTarget(self, action: #selector(handleDeliveryFailedTap), for: .touchUpInside) contentView.addSubview(deliveryFailedButton) - // Interactions - let contextMenu = UIContextMenuInteraction(delegate: self) - bubbleView.addInteraction(contextMenu) + // Long-press → Telegram context menu + let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:))) + longPress.minimumPressDuration = 0.35 + bubbleView.addGestureRecognizer(longPress) let pan = UIPanGestureRecognizer(target: self, action: #selector(handleSwipe(_:))) pan.delegate = self @@ -527,6 +528,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel // Title (16pt medium — Telegram parity) fileNameLabel.font = UIFont.systemFont(ofSize: 16, weight: .medium) + fileNameLabel.textColor = .white if isMissed { fileNameLabel.text = isIncoming ? "Missed Call" : "Cancelled Call" } else { @@ -998,36 +1000,45 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel return attrs } - // MARK: - Context Menu + // MARK: - Context Menu (Telegram-style) - func contextMenuInteraction( - _ interaction: UIContextMenuInteraction, - configurationForMenuAtLocation location: CGPoint - ) -> UIContextMenuConfiguration? { - guard let message, let actions else { return nil } - return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in - var items: [UIAction] = [] - if !message.text.isEmpty { - items.append(UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc")) { _ in - actions.onCopy(message.text) - }) - } - // Desktop parity: system accounts + ATTACHMENTS_NOT_ALLOWED_TO_REPLY = [AVATAR, MESSAGES] - let isAvatarOrForwarded = message.attachments.contains(where: { $0.type == .avatar || $0.type == .messages }) - let canReplyForward = !self.isSavedMessages && !self.isSystemAccount && !isAvatarOrForwarded - if canReplyForward { - items.append(UIAction(title: "Reply", image: UIImage(systemName: "arrowshape.turn.up.left")) { _ in - actions.onReply(message) - }) - items.append(UIAction(title: "Forward", image: UIImage(systemName: "arrowshape.turn.up.right")) { _ in - actions.onForward(message) - }) - } - items.append(UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { _ in - actions.onDelete(message) - }) - return UIMenu(children: items) - } + private let contextMenuHaptic = UIImpactFeedbackGenerator(style: .medium) + + @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) { + guard gesture.state == .began else { return } + contextMenuHaptic.impactOccurred() + presentContextMenu() + } + + private func presentContextMenu() { + guard let message, let actions else { return } + guard let layout = currentLayout else { return } + + // Capture snapshot from window (pixel-perfect, accounts for inverted scroll) + guard let (snapshot, frame) = TelegramContextMenuController.captureSnapshot(of: bubbleView) else { return } + + // Build bubble mask path + let bubblePath = BubbleGeometryEngine.makeBezierPath( + in: CGRect(origin: .zero, size: frame.size), + mergeType: layout.mergeType, + outgoing: layout.isOutgoing + ) + + // Build menu items + let items = TelegramContextMenuBuilder.menuItems( + for: message, + actions: actions, + isSavedMessages: isSavedMessages, + isSystemAccount: isSystemAccount + ) + + TelegramContextMenuController.present( + snapshot: snapshot, + sourceFrame: frame, + bubblePath: bubblePath, + items: items, + isOutgoing: layout.isOutgoing + ) } // MARK: - Swipe to Reply diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index 662ce29..e69a1c4 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -17,9 +17,9 @@ final class NativeMessageListController: UIViewController { static let messageToComposerGap: CGFloat = 16 static let scrollButtonSize: CGFloat = 40 static let scrollButtonIconCanvas: CGFloat = 38 - static let scrollButtonBaseTrailing: CGFloat = 8 + static let scrollButtonBaseTrailing: CGFloat = 17 static let scrollButtonCompactExtraTrailing: CGFloat = 18 - static let scrollButtonBottomOffset: CGFloat = 20 + } // MARK: - Configuration @@ -363,7 +363,7 @@ final class NativeMessageListController: UIViewController { ) let bottom = container.bottomAnchor.constraint( equalTo: view.keyboardLayoutGuide.topAnchor, - constant: -(lastComposerHeight + UIConstants.scrollButtonBottomOffset) + constant: -(lastComposerHeight + 4) ) NSLayoutConstraint.activate([ container.widthAnchor.constraint(equalToConstant: size), @@ -448,7 +448,7 @@ final class NativeMessageListController: UIViewController { let safeBottom = view.safeAreaInsets.bottom let compactShift = safeBottom <= 32 ? UIConstants.scrollButtonCompactExtraTrailing : 0 scrollToBottomTrailingConstraint?.constant = -(UIConstants.scrollButtonBaseTrailing + compactShift) - scrollToBottomBottomConstraint?.constant = -(lastComposerHeight + UIConstants.scrollButtonBottomOffset) + scrollToBottomBottomConstraint?.constant = -(lastComposerHeight + 4) } private func updateScrollToBottomBadge() { @@ -491,10 +491,12 @@ final class NativeMessageListController: UIViewController { gc.setLineCap(.round) gc.setLineJoin(.round) - let position = CGPoint(x: 9.0 - 0.5, y: 23.0) - gc.move(to: CGPoint(x: position.x + 1.0, y: position.y - 1.0)) - gc.addLine(to: CGPoint(x: position.x + 10.0, y: position.y - 10.0)) - gc.addLine(to: CGPoint(x: position.x + 19.0, y: position.y - 1.0)) + // Down chevron (v), centered in canvas — Telegram parity. + let cx = size.width / 2 + let cy = size.height / 2 + gc.move(to: CGPoint(x: cx - 9.0, y: cy - 4.5)) // top-left + gc.addLine(to: CGPoint(x: cx, y: cy + 4.5)) // bottom-center + gc.addLine(to: CGPoint(x: cx + 9.0, y: cy - 4.5)) // top-right gc.strokePath() }.withRenderingMode(.alwaysOriginal) } @@ -1048,6 +1050,11 @@ struct NativeMessageListView: UIViewControllerRepresentable { private func syncComposerState(_ controller: NativeMessageListController) { guard let composer = controller.composerView else { return } composer.setText(messageText) + #if DEBUG + if replySenderName != nil { + print("📋 syncComposer: sender=\(replySenderName ?? "nil") preview=\(replyPreviewText ?? "nil")") + } + #endif composer.setReply(senderName: replySenderName, previewText: replyPreviewText) composer.setFocused(isInputFocused) } diff --git a/Rosetta/Features/Chats/ChatDetail/NativeTextBubbleCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeTextBubbleCell.swift index 756680f..98eb9e1 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeTextBubbleCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeTextBubbleCell.swift @@ -3,7 +3,7 @@ import UIKit /// Pure UIKit message cell for text messages (with optional reply quote). /// Replaces UIHostingConfiguration + SwiftUI for the most common message type. /// Features: Figma-accurate bubble tail, context menu, swipe-to-reply, reply quote. -final class NativeTextBubbleCell: UICollectionViewCell, UIContextMenuInteractionDelegate { +final class NativeTextBubbleCell: UICollectionViewCell { // MARK: - Constants @@ -110,9 +110,10 @@ final class NativeTextBubbleCell: UICollectionViewCell, UIContextMenuInteraction replyIconView.alpha = 0 contentView.addSubview(replyIconView) - // Context menu - let contextMenu = UIContextMenuInteraction(delegate: self) - bubbleView.addInteraction(contextMenu) + // Long-press → Telegram context menu + let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:))) + longPress.minimumPressDuration = 0.35 + bubbleView.addGestureRecognizer(longPress) // Swipe-to-reply gesture let pan = UIPanGestureRecognizer(target: self, action: #selector(handleSwipe(_:))) @@ -383,32 +384,45 @@ final class NativeTextBubbleCell: UICollectionViewCell, UIContextMenuInteraction } } - // MARK: - Context Menu + // MARK: - Context Menu (Telegram-style) - func contextMenuInteraction( - _ interaction: UIContextMenuInteraction, - configurationForMenuAtLocation location: CGPoint - ) -> UIContextMenuConfiguration? { - guard let message, let actions else { return nil } + private let contextMenuHaptic = UIImpactFeedbackGenerator(style: .medium) - return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in - var items: [UIAction] = [] + @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) { + guard gesture.state == .began else { return } + contextMenuHaptic.impactOccurred() + presentContextMenu() + } - items.append(UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc")) { _ in - actions.onCopy(message.text) - }) - items.append(UIAction(title: "Reply", image: UIImage(systemName: "arrowshape.turn.up.left")) { _ in - actions.onReply(message) - }) - items.append(UIAction(title: "Forward", image: UIImage(systemName: "arrowshape.turn.up.right")) { _ in - actions.onForward(message) - }) - items.append(UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { _ in - actions.onDelete(message) - }) + private func presentContextMenu() { + guard let message, let actions else { return } - return UIMenu(children: items) - } + // Capture snapshot from window + guard let (snapshot, frame) = TelegramContextMenuController.captureSnapshot(of: bubbleView) else { return } + + // Build bubble mask path + let mergeType = BubbleGeometryEngine.mergeType(for: position) + let bubblePath = BubbleGeometryEngine.makeBezierPath( + in: CGRect(origin: .zero, size: frame.size), + mergeType: mergeType, + outgoing: isOutgoing + ) + + // Build menu items + let items = TelegramContextMenuBuilder.menuItems( + for: message, + actions: actions, + isSavedMessages: false, + isSystemAccount: false + ) + + TelegramContextMenuController.present( + snapshot: snapshot, + sourceFrame: frame, + bubblePath: bubblePath, + items: items, + isOutgoing: isOutgoing + ) } // MARK: - Swipe to Reply diff --git a/Rosetta/Features/Chats/ChatDetail/TelegramContextMenuCardView.swift b/Rosetta/Features/Chats/ChatDetail/TelegramContextMenuCardView.swift new file mode 100644 index 0000000..60656f5 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/TelegramContextMenuCardView.swift @@ -0,0 +1,178 @@ +import UIKit + +/// Telegram-exact context menu card. +/// +/// Source: ContextActionsContainerNode.swift + DefaultDarkPresentationTheme.swift +/// - Background: .systemMaterialDark blur + UIColor(0x252525, alpha: 0.78) tint +/// - Items: 17pt font, icon LEFT + title RIGHT +/// - Corner radius: 14pt continuous +/// - Separator: screenPixel, white 15% alpha, full width +/// - Destructive: 0xeb5545 +final class TelegramContextMenuCardView: UIView { + + // MARK: - Constants + + private static let itemHeight: CGFloat = 44 + private static let cornerRadius: CGFloat = 14 + private static let hPad: CGFloat = 16 + private static let iconSize: CGFloat = 24 + private static let screenPixel = 1.0 / max(UIScreen.main.scale, 1) + + // MARK: - Colors (Telegram dark theme) + + private static let tintBg = UIColor(red: 0x25/255, green: 0x25/255, blue: 0x25/255, alpha: 0.78) + private static let textColor = UIColor.white + private static let destructiveColor = UIColor(red: 0xEB/255, green: 0x55/255, blue: 0x45/255, alpha: 1) + private static let separatorColor = UIColor(white: 1, alpha: 0.15) + private static let highlightColor = UIColor(white: 1, alpha: 0.15) + + // MARK: - Public + + let itemCount: Int + var onItemSelected: (() -> Void)? + + // MARK: - Views + + private let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterialDark)) + private let tintView = UIView() + private let items: [TelegramContextMenuItem] + + // Row subviews stored for layout + private var titleLabels: [UILabel] = [] + private var iconViews: [UIImageView] = [] + private var highlightViews: [UIView] = [] + private var separators: [UIView] = [] + + // MARK: - Init + + init(items: [TelegramContextMenuItem]) { + self.items = items + self.itemCount = items.count + super.init(frame: .zero) + setup() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + // MARK: - Setup + + private func setup() { + clipsToBounds = true + layer.cornerRadius = Self.cornerRadius + layer.cornerCurve = .continuous + + blurView.clipsToBounds = true + addSubview(blurView) + + tintView.backgroundColor = Self.tintBg + tintView.isUserInteractionEnabled = false + addSubview(tintView) + + for (i, item) in items.enumerated() { + let color = item.isDestructive ? Self.destructiveColor : Self.textColor + + // Highlight + let hl = UIView() + hl.backgroundColor = Self.highlightColor + hl.alpha = 0 + addSubview(hl) + highlightViews.append(hl) + + // Title + let label = UILabel() + label.text = item.title + label.font = .systemFont(ofSize: 17, weight: .regular) + label.textColor = color + addSubview(label) + titleLabels.append(label) + + // Icon + let iv = UIImageView() + iv.image = UIImage(systemName: item.iconName)? + .withConfiguration(UIImage.SymbolConfiguration(pointSize: Self.iconSize, weight: .medium)) + iv.tintColor = color + iv.contentMode = .center + addSubview(iv) + iconViews.append(iv) + + // Separator + if i < items.count - 1 { + let sep = UIView() + sep.backgroundColor = Self.separatorColor + addSubview(sep) + separators.append(sep) + } + + // Row gesture + let rowView = UIView() + rowView.backgroundColor = .clear + rowView.tag = i + let press = UILongPressGestureRecognizer(target: self, action: #selector(rowPress(_:))) + press.minimumPressDuration = 0 + press.cancelsTouchesInView = false + rowView.addGestureRecognizer(press) + addSubview(rowView) + } + } + + // MARK: - Layout + + override func layoutSubviews() { + super.layoutSubviews() + let w = bounds.width + blurView.frame = bounds + tintView.frame = bounds + + for i in 0..= 0, i < items.count else { return } + + switch g.state { + case .began: + UIView.animate(withDuration: 0.08) { self.highlightViews[i].alpha = 1 } + case .ended: + let loc = g.location(in: g.view) + UIView.animate(withDuration: 0.12) { self.highlightViews[i].alpha = 0 } + if g.view?.bounds.contains(loc) == true { + let handler = items[i].handler + onItemSelected?() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { handler() } + } + case .cancelled, .failed: + UIView.animate(withDuration: 0.12) { self.highlightViews[i].alpha = 0 } + default: break + } + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/TelegramContextMenuController.swift b/Rosetta/Features/Chats/ChatDetail/TelegramContextMenuController.swift new file mode 100644 index 0000000..a946351 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/TelegramContextMenuController.swift @@ -0,0 +1,370 @@ +import UIKit + +// MARK: - Menu Item Model + +struct TelegramContextMenuItem { + let title: String + let iconName: String + let isDestructive: Bool + let handler: () -> Void +} + +// MARK: - Shared Builder + +enum TelegramContextMenuBuilder { + + static func menuItems( + for message: ChatMessage, + actions: MessageCellActions, + isSavedMessages: Bool, + isSystemAccount: Bool + ) -> [TelegramContextMenuItem] { + var items: [TelegramContextMenuItem] = [] + + let isAvatarOrForwarded = message.attachments.contains(where: { + $0.type == .avatar || $0.type == .messages + }) + let canReplyForward = !isSavedMessages && !isSystemAccount && !isAvatarOrForwarded + + if canReplyForward { + items.append(TelegramContextMenuItem( + title: "Reply", + iconName: "arrowshape.turn.up.left", + isDestructive: false, + handler: { actions.onReply(message) } + )) + } + + if !message.text.isEmpty { + items.append(TelegramContextMenuItem( + title: "Copy", + iconName: "doc.on.doc", + isDestructive: false, + handler: { actions.onCopy(message.text) } + )) + } + + if canReplyForward { + items.append(TelegramContextMenuItem( + title: "Forward", + iconName: "arrowshape.turn.up.right", + isDestructive: false, + handler: { actions.onForward(message) } + )) + } + + items.append(TelegramContextMenuItem( + title: "Delete", + iconName: "trash", + isDestructive: true, + handler: { actions.onDelete(message) } + )) + + return items + } +} + +// MARK: - Context Menu Controller + +/// Full-screen overlay replicating Telegram iOS context menu. +/// Telegram source: ContextController.swift, DefaultDarkPresentationTheme.swift +final class TelegramContextMenuController: UIView { + + // MARK: - Constants + + private static let menuWidth: CGFloat = 250 + private static let menuItemHeight: CGFloat = 44 + private static let menuGap: CGFloat = 8 + private static let panDismissThreshold: CGFloat = 100 + private static let panVelocityThreshold: CGFloat = 800 + + // MARK: - Subviews + + /// Telegram: UIVisualEffectView(.dark) + UIColor(rgb: 0x000000, alpha: 0.6) dim + private let backgroundBlurView = UIVisualEffectView(effect: UIBlurEffect(style: .dark)) + private let dimView = UIView() + private let snapshotContainer = UIView() + private let snapshotImageView = UIImageView() + private let menuCard: TelegramContextMenuCardView + + // MARK: - State + + private let sourceFrame: CGRect + private let isOutgoing: Bool + private var onDismiss: (() -> Void)? + private var isDismissing = false + private var panStartSnapshotCenter: CGPoint = .zero + private var panStartMenuCenter: CGPoint = .zero + + // MARK: - Init + + private init( + snapshot: UIImage, + sourceFrame: CGRect, + bubblePath: UIBezierPath, + items: [TelegramContextMenuItem], + isOutgoing: Bool, + onDismiss: (() -> Void)? + ) { + self.sourceFrame = sourceFrame + self.isOutgoing = isOutgoing + self.onDismiss = onDismiss + self.menuCard = TelegramContextMenuCardView(items: items) + super.init(frame: .zero) + buildHierarchy(snapshot: snapshot, bubblePath: bubblePath) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + // MARK: - Public API + + @MainActor + static func present( + snapshot: UIImage, + sourceFrame: CGRect, + bubblePath: UIBezierPath, + items: [TelegramContextMenuItem], + isOutgoing: Bool, + onDismiss: (() -> Void)? = nil + ) { + guard !items.isEmpty else { return } + guard let scene = UIApplication.shared.connectedScenes + .first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene, + let window = scene.windows.first(where: \.isKeyWindow) + else { return } + + // Telegram always dismisses keyboard before showing context menu + window.endEditing(true) + + let overlay = TelegramContextMenuController( + snapshot: snapshot, + sourceFrame: sourceFrame, + bubblePath: bubblePath, + items: items, + isOutgoing: isOutgoing, + onDismiss: onDismiss + ) + overlay.frame = window.bounds + overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight] + window.addSubview(overlay) + overlay.performPresentation() + } + + // MARK: - Build Hierarchy + + private func buildHierarchy(snapshot: UIImage, bubblePath: UIBezierPath) { + // 1. Full-screen blur (Telegram: custom zoom blur + .systemMaterialDark) + backgroundBlurView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + addSubview(backgroundBlurView) + + // 2. Dim overlay on top of blur (Telegram: UIColor(rgb: 0x000000, alpha: 0.6)) + dimView.backgroundColor = UIColor(white: 0, alpha: 0.6) + dimView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + addSubview(dimView) + + // 2. Snapshot container + snapshotContainer.backgroundColor = .clear + snapshotImageView.image = snapshot + snapshotImageView.contentMode = .scaleToFill + + let mask = CAShapeLayer() + mask.path = bubblePath.cgPath + snapshotImageView.layer.mask = mask + + snapshotContainer.layer.shadowColor = UIColor.black.cgColor + snapshotContainer.layer.shadowOpacity = 0.3 + snapshotContainer.layer.shadowRadius = 12 + snapshotContainer.layer.shadowOffset = CGSize(width: 0, height: 4) + snapshotContainer.layer.shadowPath = bubblePath.cgPath + + snapshotContainer.addSubview(snapshotImageView) + addSubview(snapshotContainer) + + // 3. Menu card + menuCard.onItemSelected = { [weak self] in + self?.performDismissal() + } + addSubview(menuCard) + + // 4. Gestures + let tap = UITapGestureRecognizer(target: self, action: #selector(tapDismiss(_:))) + tap.delegate = self + addGestureRecognizer(tap) + + let pan = UIPanGestureRecognizer(target: self, action: #selector(panDismiss(_:))) + addGestureRecognizer(pan) + } + + // MARK: - Layout + + /// Positions snapshot + menu card. MUST be called with identity transforms. + private func layoutContent() { + let windowInsets = window?.safeAreaInsets ?? .zero + let safeTop = max(windowInsets.top, 20) + let safeBottom = max(windowInsets.bottom, 20) + + // Snapshot at original bubble position + snapshotContainer.frame = sourceFrame + snapshotImageView.frame = snapshotContainer.bounds + + // Menu dimensions + let menuW = Self.menuWidth + let menuH = CGFloat(menuCard.itemCount) * Self.menuItemHeight + let gap = Self.menuGap + + // Vertical: prefer below, then above, then shift + let belowSpace = bounds.height - safeBottom - sourceFrame.maxY + let aboveSpace = sourceFrame.minY - safeTop + + var menuY: CGFloat + var menuAbove = false + + if belowSpace >= menuH + gap { + menuY = sourceFrame.maxY + gap + } else if aboveSpace >= menuH + gap { + menuY = sourceFrame.minY - gap - menuH + menuAbove = true + } else { + // Shift snapshot up to make room below + let shift = (menuH + gap) - belowSpace + snapshotContainer.frame.origin.y -= shift + menuY = snapshotContainer.frame.maxY + gap + } + + // Horizontal: align with bubble side + let menuX: CGFloat + if isOutgoing { + menuX = min(sourceFrame.maxX - menuW, bounds.width - menuW - 8) + } else { + menuX = max(sourceFrame.minX, 8) + } + + menuCard.frame = CGRect(x: menuX, y: menuY, width: menuW, height: menuH) + + // Anchor for scale animation — scale from the edge nearest to the bubble + let anchorX: CGFloat = isOutgoing ? 1.0 : 0.0 + let anchorY: CGFloat = menuAbove ? 1.0 : 0.0 + setAnchorPointPreservingPosition(CGPoint(x: anchorX, y: anchorY), for: menuCard) + } + + /// Changes anchor point without moving the view. + private func setAnchorPointPreservingPosition(_ anchor: CGPoint, for view: UIView) { + let oldAnchor = view.layer.anchorPoint + let delta = CGPoint( + x: (anchor.x - oldAnchor.x) * view.bounds.width, + y: (anchor.y - oldAnchor.y) * view.bounds.height + ) + view.layer.anchorPoint = anchor + view.layer.position = CGPoint( + x: view.layer.position.x + delta.x, + y: view.layer.position.y + delta.y + ) + } + + // MARK: - Presentation + + private func performPresentation() { + // Layout with identity transforms (CRITICAL — frame + transform interaction) + backgroundBlurView.frame = bounds + dimView.frame = bounds + layoutContent() + + // Set initial pre-animation state + backgroundBlurView.alpha = 0 + dimView.alpha = 0 + snapshotContainer.alpha = 0 + snapshotContainer.transform = CGAffineTransform(scaleX: 0.96, y: 0.96) + menuCard.alpha = 0 + menuCard.transform = CGAffineTransform(scaleX: 0.01, y: 0.01) + + // Telegram spring: mass 5, stiffness 900, damping 88 + let spring = UISpringTimingParameters( + mass: 5.0, stiffness: 900.0, damping: 88.0, initialVelocity: .zero + ) + + let animator = UIViewPropertyAnimator(duration: 0, timingParameters: spring) + animator.addAnimations { + self.backgroundBlurView.alpha = 1 + self.dimView.alpha = 1 + self.snapshotContainer.alpha = 1 + self.snapshotContainer.transform = .identity + self.menuCard.alpha = 1 + self.menuCard.transform = .identity + } + animator.startAnimation() + } + + // MARK: - Dismissal + + private func performDismissal() { + guard !isDismissing else { return } + isDismissing = true + + UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseIn) { + self.backgroundBlurView.alpha = 0 + self.dimView.alpha = 0 + self.snapshotContainer.alpha = 0 + self.menuCard.alpha = 0 + self.menuCard.transform = CGAffineTransform(scaleX: 0.01, y: 0.01) + } completion: { [weak self] _ in + self?.onDismiss?() + self?.removeFromSuperview() + } + } + + // MARK: - Gestures + + @objc private func tapDismiss(_ g: UITapGestureRecognizer) { + performDismissal() + } + + @objc private func panDismiss(_ g: UIPanGestureRecognizer) { + switch g.state { + case .began: + panStartSnapshotCenter = snapshotContainer.center + panStartMenuCenter = menuCard.center + case .changed: + let dy = max(g.translation(in: self).y, 0) * 0.6 + snapshotContainer.center = CGPoint(x: panStartSnapshotCenter.x, y: panStartSnapshotCenter.y + dy) + menuCard.center = CGPoint(x: panStartMenuCenter.x, y: panStartMenuCenter.y + dy) + dimView.alpha = max(1 - dy / 300, 0.3) + case .ended, .cancelled: + if g.translation(in: self).y > Self.panDismissThreshold || g.velocity(in: self).y > Self.panVelocityThreshold { + performDismissal() + } else { + UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0) { + self.snapshotContainer.center = self.panStartSnapshotCenter + self.menuCard.center = self.panStartMenuCenter + self.dimView.alpha = 1 + } + } + default: break + } + } +} + +// MARK: - UIGestureRecognizerDelegate + +extension TelegramContextMenuController: UIGestureRecognizerDelegate { + func gestureRecognizer(_ g: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + guard g is UITapGestureRecognizer else { return true } + let loc = touch.location(in: self) + return !snapshotContainer.frame.contains(loc) && !menuCard.frame.contains(loc) + } +} + +// MARK: - Snapshot Helper + +extension TelegramContextMenuController { + static func captureSnapshot(of sourceView: UIView) -> (image: UIImage, frame: CGRect)? { + guard let window = sourceView.window else { return nil } + let frameInWindow = sourceView.convert(sourceView.bounds, to: window) + let renderer = UIGraphicsImageRenderer(size: sourceView.bounds.size) + let image = renderer.image { ctx in + ctx.cgContext.translateBy(x: -frameInWindow.origin.x, y: -frameInWindow.origin.y) + window.drawHierarchy(in: window.bounds, afterScreenUpdates: false) + } + return (image, frameInWindow) + } +} diff --git a/Rosetta/Features/MainTabView.swift b/Rosetta/Features/MainTabView.swift index 96b34c5..e3f877b 100644 --- a/Rosetta/Features/MainTabView.swift +++ b/Rosetta/Features/MainTabView.swift @@ -65,9 +65,23 @@ struct MainTabView: View { // never observes the dialogs dictionary directly. UnreadCountObserver(count: $cachedUnreadCount) - if callManager.uiState.isVisible { - ActiveCallOverlayView(callManager: callManager) - .zIndex(10) + // Full-screen call overlay + // Animation driven by withAnimation in CallManager methods — + // no .animation() modifiers here to avoid NavigationStack conflicts. + Group { + if callManager.uiState.isFullScreenVisible { + ActiveCallOverlayView(callManager: callManager) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .zIndex(10) + } + .safeAreaInset(edge: .top, spacing: 0) { + if callManager.uiState.isVisible && callManager.uiState.isMinimized { + MinimizedCallBar(callManager: callManager) + .transition(.move(edge: .top).combined(with: .opacity)) + } else { + EmptyView() } } // Switch to Chats tab when user taps a push notification. @@ -195,16 +209,26 @@ struct MainTabView: View { isSearchActive: $isChatSearchActive, isDetailPresented: $isChatListDetailPresented ) + .callBarSafeAreaInset(callBarTopPadding) case .calls: CallsView() + .callBarSafeAreaInset(callBarTopPadding) case .settings: SettingsView(onLogout: onLogout, onAddAccount: handleAddAccount, isEditingProfile: $isSettingsEditPresented, isDetailPresented: $isSettingsDetailPresented) + .callBarSafeAreaInset(callBarTopPadding) } } else { RosettaColors.Adaptive.background } } + /// Call bar height for UIKit `additionalSafeAreaInsets` — pushes nav bar down. + /// Telegram uses safeInsets.top + 20 (Dynamic Island) / +12 (notch). + /// 36pt gives a comfortable visible strip below the Dynamic Island. + private var callBarTopPadding: CGFloat { + (callManager.uiState.isVisible && callManager.uiState.isMinimized) ? 36 : 0 + } + private var isAnyChatDetailPresented: Bool { isChatListDetailPresented }