Минимизированный call-бар: UIKit additionalSafeAreaInsets для сдвига навбара, Telegram-style градиент и UI-рефакторинг

This commit is contained in:
2026-03-30 04:24:48 +05:00
parent 2b25c87a6a
commit f24f7ee555
20 changed files with 1444 additions and 439 deletions

View File

@@ -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 = (

View File

@@ -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()

View File

@@ -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()

View File

@@ -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)" }

View 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)
)
}
}

View 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)
}
}

View 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
}
}
}
}

View File

@@ -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
}
}
}
}

View 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
}
}
}

View 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
)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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
}
}
}

View File

@@ -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)
}
}

View File

@@ -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
}