Минимизированный call-бар: UIKit additionalSafeAreaInsets для сдвига навбара, Telegram-style градиент и UI-рефакторинг
This commit is contained in:
@@ -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 = "<group>"; };
|
||||
DBAA4AD95B61886B5A22EF0D /* MigrationHarnessTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MigrationHarnessTests.swift; sourceTree = "<group>"; };
|
||||
E20000042F8D11110092AD05 /* WebRTC.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = WebRTC.xcframework; path = Frameworks/WebRTC.xcframework; sourceTree = "<group>"; };
|
||||
|
||||
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 = "<group>"; };
|
||||
LA000000E2F8D22220092AD05 /* CallLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallLiveActivity.swift; sourceTree = "<group>"; };
|
||||
LA000000F2F8D22220092AD05 /* CallActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallActivityAttributes.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
@@ -154,7 +148,6 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
LA00000042F8D22220092AD05 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -227,7 +220,6 @@
|
||||
path = RosettaNotificationService;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
|
||||
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 = (
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)" }
|
||||
|
||||
69
Rosetta/DesignSystem/Components/CallBarSafeAreaBridge.swift
Normal file
69
Rosetta/DesignSystem/Components/CallBarSafeAreaBridge.swift
Normal file
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
124
Rosetta/DesignSystem/Components/CallGradientBackground.swift
Normal file
124
Rosetta/DesignSystem/Components/CallGradientBackground.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
57
Rosetta/DesignSystem/Components/CallPulsingRings.swift
Normal file
57
Rosetta/DesignSystem/Components/CallPulsingRings.swift
Normal file
@@ -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..<ringCount, id: \.self) { index in
|
||||
RippleRing(
|
||||
color: color,
|
||||
duration: ringDuration,
|
||||
delay: Double(index) * ringDelay,
|
||||
maxScale: maxScale
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(width: size * maxScale + 2, height: size * maxScale + 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Individual Ring
|
||||
|
||||
private struct RippleRing: View {
|
||||
let color: Color
|
||||
let duration: Double
|
||||
let delay: Double
|
||||
let maxScale: CGFloat
|
||||
|
||||
@State private var isAnimating = false
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.stroke(color.opacity(0.12), lineWidth: 1.5)
|
||||
.background(Circle().fill(color.opacity(0.04)))
|
||||
.scaleEffect(isAnimating ? maxScale : 1.0)
|
||||
.opacity(isAnimating ? 0.0 : 0.6)
|
||||
.task {
|
||||
try? await Task.sleep(for: .seconds(delay))
|
||||
withAnimation(
|
||||
.easeOut(duration: duration)
|
||||
.repeatForever(autoreverses: false)
|
||||
) {
|
||||
isAnimating = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Full-Screen Call Overlay (Telegram-style)
|
||||
|
||||
struct ActiveCallOverlayView: View {
|
||||
@ObservedObject var callManager: CallManager
|
||||
@State private var peerAvatar: UIImage?
|
||||
@State private var dragOffset: CGFloat = 0
|
||||
|
||||
private var state: CallUiState {
|
||||
callManager.uiState
|
||||
@@ -11,11 +15,14 @@ struct ActiveCallOverlayView: View {
|
||||
let duration = max(state.durationSec, 0)
|
||||
let minutes = duration / 60
|
||||
let seconds = duration % 60
|
||||
return String(format: "%02d:%02d", minutes, seconds)
|
||||
return String(format: "%d:%02d", minutes, seconds)
|
||||
}
|
||||
|
||||
private var peerInitials: String {
|
||||
RosettaColors.initials(name: state.peerTitle.isEmpty ? state.peerUsername : state.peerTitle, publicKey: state.peerPublicKey)
|
||||
RosettaColors.initials(
|
||||
name: state.peerTitle.isEmpty ? state.peerUsername : state.peerTitle,
|
||||
publicKey: state.peerPublicKey
|
||||
)
|
||||
}
|
||||
|
||||
private var peerColorIndex: Int {
|
||||
@@ -24,134 +31,162 @@ struct ActiveCallOverlayView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.opacity(0.7)
|
||||
.ignoresSafeArea()
|
||||
// Animated gradient background
|
||||
CallGradientBackground(colorIndex: peerColorIndex)
|
||||
|
||||
VStack(spacing: 20) {
|
||||
// Content
|
||||
VStack(spacing: 0) {
|
||||
// Top bar: Back button + E2E badge
|
||||
topBar
|
||||
.padding(.top, 12)
|
||||
|
||||
Spacer()
|
||||
.frame(minHeight: 20, maxHeight: .infinity)
|
||||
.layoutPriority(1)
|
||||
|
||||
// Avatar with pulsing rings
|
||||
avatarSection
|
||||
|
||||
// Name (Telegram: 28pt regular)
|
||||
Text(state.displayName)
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.font(.system(size: 28, weight: .regular))
|
||||
.foregroundStyle(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 39)
|
||||
|
||||
if state.phase == .active {
|
||||
Text(durationText)
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(Color.white.opacity(0.85))
|
||||
} else {
|
||||
Text(statusText(for: state.phase))
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(Color.white.opacity(0.85))
|
||||
}
|
||||
// Status / Duration (Telegram: 15pt)
|
||||
statusSection
|
||||
.padding(.top, 6)
|
||||
|
||||
controls
|
||||
Spacer()
|
||||
.frame(minHeight: 40, maxHeight: .infinity)
|
||||
.layoutPriority(2)
|
||||
|
||||
// Action buttons
|
||||
CallActionButtonsView(callManager: callManager)
|
||||
.padding(.bottom, 52)
|
||||
}
|
||||
.padding(28)
|
||||
.frame(maxWidth: 360)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||
.fill(Color.black.opacity(0.62))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.15), lineWidth: 1)
|
||||
)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.95)))
|
||||
.offset(y: dragOffset)
|
||||
.gesture(minimizeGesture)
|
||||
.statusBarHidden(true)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
// MARK: - Top Bar
|
||||
|
||||
private var topBar: some View {
|
||||
ZStack {
|
||||
// E2E badge (centered)
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 12))
|
||||
Text("End-to-end Encrypted")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
}
|
||||
.foregroundStyle(Color.white.opacity(0.5))
|
||||
|
||||
// Back button (left-aligned)
|
||||
HStack {
|
||||
Button {
|
||||
callManager.minimizeCall()
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
Text("Back")
|
||||
.font(.system(size: 17))
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
}
|
||||
|
||||
// MARK: - Avatar (Telegram: 136pt)
|
||||
|
||||
private var avatarSection: some View {
|
||||
ZStack {
|
||||
if state.phase != .active {
|
||||
PulsingRings()
|
||||
CallPulsingRings(size: 136)
|
||||
}
|
||||
|
||||
AvatarView(
|
||||
initials: peerInitials,
|
||||
colorIndex: peerColorIndex,
|
||||
size: 90,
|
||||
image: AvatarRepository.shared.loadAvatar(publicKey: state.peerPublicKey)
|
||||
size: 136,
|
||||
image: peerAvatar
|
||||
)
|
||||
}
|
||||
.frame(width: 130, height: 130)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var controls: some View {
|
||||
if state.phase == .incoming {
|
||||
HStack(spacing: 16) {
|
||||
callActionButton(
|
||||
title: "Decline",
|
||||
icon: "phone.down.fill",
|
||||
color: RosettaColors.error
|
||||
) {
|
||||
callManager.declineIncomingCall()
|
||||
}
|
||||
callActionButton(
|
||||
title: "Accept",
|
||||
icon: "phone.fill",
|
||||
color: RosettaColors.success
|
||||
) {
|
||||
_ = callManager.acceptIncomingCall()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 16) {
|
||||
callActionButton(
|
||||
title: state.isMuted ? "Unmute" : "Mute",
|
||||
icon: state.isMuted ? "mic.slash.fill" : "mic.fill",
|
||||
color: Color.white.opacity(0.18)
|
||||
) {
|
||||
callManager.toggleMute()
|
||||
}
|
||||
callActionButton(
|
||||
title: state.isSpeakerOn ? "Earpiece" : "Speaker",
|
||||
icon: state.isSpeakerOn ? "speaker.slash.fill" : "speaker.wave.2.fill",
|
||||
color: Color.white.opacity(0.18)
|
||||
) {
|
||||
callManager.toggleSpeaker()
|
||||
}
|
||||
callActionButton(
|
||||
title: "End",
|
||||
icon: "phone.down.fill",
|
||||
color: RosettaColors.error
|
||||
) {
|
||||
callManager.endCall()
|
||||
}
|
||||
}
|
||||
.frame(width: 250, height: 250)
|
||||
.task(id: state.peerPublicKey) {
|
||||
peerAvatar = AvatarRepository.shared.loadAvatar(publicKey: state.peerPublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func callActionButton(
|
||||
title: String,
|
||||
icon: String,
|
||||
color: Color,
|
||||
action: @escaping () -> 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
147
Rosetta/Features/Calls/CallActionButtons.swift
Normal file
147
Rosetta/Features/Calls/CallActionButtons.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
86
Rosetta/Features/Calls/MinimizedCallBar.swift
Normal file
86
Rosetta/Features/Calls/MinimizedCallBar.swift
Normal file
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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..<items.count {
|
||||
let y = CGFloat(i) * Self.itemHeight
|
||||
let rowRect = CGRect(x: 0, y: y, width: w, height: Self.itemHeight)
|
||||
|
||||
// Highlight fills row
|
||||
highlightViews[i].frame = rowRect
|
||||
|
||||
// Icon left (Telegram: icon on LEFT side)
|
||||
let iconX = Self.hPad
|
||||
iconViews[i].frame = CGRect(x: iconX, y: y, width: Self.iconSize, height: Self.itemHeight)
|
||||
|
||||
// Title right of icon (Telegram: text follows icon)
|
||||
let titleX = Self.hPad + Self.iconSize + 12
|
||||
let titleW = w - titleX - Self.hPad
|
||||
titleLabels[i].frame = CGRect(x: titleX, y: y, width: titleW, height: Self.itemHeight)
|
||||
|
||||
// Gesture receiver (topmost, transparent)
|
||||
if let rv = subviews.first(where: { $0.tag == i && $0.gestureRecognizers?.isEmpty == false }) {
|
||||
rv.frame = rowRect
|
||||
}
|
||||
}
|
||||
|
||||
// Separators
|
||||
for (i, sep) in separators.enumerated() {
|
||||
let y = CGFloat(i + 1) * Self.itemHeight
|
||||
sep.frame = CGRect(x: 0, y: y, width: w, height: Self.screenPixel)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func rowPress(_ g: UILongPressGestureRecognizer) {
|
||||
let i = g.view?.tag ?? -1
|
||||
guard i >= 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user