Минимизированный call-бар: UIKit additionalSafeAreaInsets для сдвига навбара, Telegram-style градиент и UI-рефакторинг
This commit is contained in:
@@ -25,11 +25,9 @@
|
|||||||
F1A000012F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000022F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport */; };
|
F1A000012F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000022F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport */; };
|
||||||
F1A000032F6F00010092AD05 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000042F6F00010092AD05 /* FirebaseMessaging */; };
|
F1A000032F6F00010092AD05 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000042F6F00010092AD05 /* FirebaseMessaging */; };
|
||||||
F1A000062F6F00010092AD05 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000072F6F00010092AD05 /* FirebaseCrashlytics */; };
|
F1A000062F6F00010092AD05 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000072F6F00010092AD05 /* FirebaseCrashlytics */; };
|
||||||
|
LA00000092F8D22220092AD05 /* RosettaLiveActivityWidget.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = LA00000022F8D22220092AD05 /* RosettaLiveActivityWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
LA00000102F8D22220092AD05 /* RosettaLiveActivityWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = LA000000D2F8D22220092AD05 /* RosettaLiveActivityWidgetBundle.swift */; };
|
|
||||||
LA00000112F8D22220092AD05 /* CallLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = LA000000E2F8D22220092AD05 /* CallLiveActivity.swift */; };
|
LA00000112F8D22220092AD05 /* CallLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = LA000000E2F8D22220092AD05 /* CallLiveActivity.swift */; };
|
||||||
LA00000122F8D22220092AD05 /* CallActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = LA000000F2F8D22220092AD05 /* CallActivityAttributes.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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -47,7 +45,6 @@
|
|||||||
remoteGlobalIDString = 853F29612F4B50410092AD05;
|
remoteGlobalIDString = 853F29612F4B50410092AD05;
|
||||||
remoteInfo = Rosetta;
|
remoteInfo = Rosetta;
|
||||||
};
|
};
|
||||||
|
|
||||||
LA000000B2F8D22220092AD05 /* PBXContainerItemProxy */ = {
|
LA000000B2F8D22220092AD05 /* PBXContainerItemProxy */ = {
|
||||||
isa = PBXContainerItemProxy;
|
isa = PBXContainerItemProxy;
|
||||||
containerPortal = 853F295A2F4B50410092AD05 /* Project object */;
|
containerPortal = 853F295A2F4B50410092AD05 /* Project object */;
|
||||||
@@ -58,18 +55,6 @@
|
|||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase 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 */ = {
|
249D2C5CD23DB96B22202215 /* Embed Foundation Extensions */ = {
|
||||||
isa = PBXCopyFilesBuildPhase;
|
isa = PBXCopyFilesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -92,6 +77,17 @@
|
|||||||
name = "Embed Frameworks";
|
name = "Embed Frameworks";
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
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 */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
@@ -108,9 +104,7 @@
|
|||||||
C9FC5C4F7E26FAFEC47C1D51 /* BehaviorParityFixtureTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BehaviorParityFixtureTests.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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>"; };
|
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>"; };
|
LA000000F2F8D22220092AD05 /* CallActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallActivityAttributes.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
@@ -154,7 +148,6 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
LA00000042F8D22220092AD05 /* Frameworks */ = {
|
LA00000042F8D22220092AD05 /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -227,7 +220,6 @@
|
|||||||
path = RosettaNotificationService;
|
path = RosettaNotificationService;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
|
||||||
LA000000C2F8D22220092AD05 /* RosettaLiveActivityWidget */ = {
|
LA000000C2F8D22220092AD05 /* RosettaLiveActivityWidget */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -273,7 +265,7 @@
|
|||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
3323872B02212359E2291EE8 /* PBXTargetDependency */,
|
3323872B02212359E2291EE8 /* PBXTargetDependency */,
|
||||||
LA000000A2F8D22220092AD05 /* PBXTargetDependency */,
|
LA000000A2F8D22220092AD05 /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
853F29642F4B50410092AD05 /* Rosetta */,
|
853F29642F4B50410092AD05 /* Rosetta */,
|
||||||
@@ -308,7 +300,6 @@
|
|||||||
productReference = A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */;
|
productReference = A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */;
|
||||||
productType = "com.apple.product-type.app-extension";
|
productType = "com.apple.product-type.app-extension";
|
||||||
};
|
};
|
||||||
|
|
||||||
LA00000012F8D22220092AD05 /* RosettaLiveActivityWidget */ = {
|
LA00000012F8D22220092AD05 /* RosettaLiveActivityWidget */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = LA00000062F8D22220092AD05 /* Build configuration list for PBXNativeTarget "RosettaLiveActivityWidget" */;
|
buildConfigurationList = LA00000062F8D22220092AD05 /* Build configuration list for PBXNativeTarget "RosettaLiveActivityWidget" */;
|
||||||
@@ -369,7 +360,7 @@
|
|||||||
targets = (
|
targets = (
|
||||||
853F29612F4B50410092AD05 /* Rosetta */,
|
853F29612F4B50410092AD05 /* Rosetta */,
|
||||||
E47730762E9823BA2D02A197 /* RosettaNotificationService */,
|
E47730762E9823BA2D02A197 /* RosettaNotificationService */,
|
||||||
LA00000012F8D22220092AD05 /* RosettaLiveActivityWidget */,
|
LA00000012F8D22220092AD05 /* RosettaLiveActivityWidget */,
|
||||||
219188CF4FCBF8E8CF11BEC2 /* RosettaTests */,
|
219188CF4FCBF8E8CF11BEC2 /* RosettaTests */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -397,7 +388,6 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
LA00000052F8D22220092AD05 /* Resources */ = {
|
LA00000052F8D22220092AD05 /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -436,12 +426,11 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
LA00000032F8D22220092AD05 /* Sources */ = {
|
LA00000032F8D22220092AD05 /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
LA00000112F8D22220092AD05 /* CallLiveActivity.swift in Sources */,
|
LA00000112F8D22220092AD05 /* CallLiveActivity.swift in Sources */,
|
||||||
LA00000122F8D22220092AD05 /* CallActivityAttributes.swift in Sources */,
|
LA00000122F8D22220092AD05 /* CallActivityAttributes.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -461,7 +450,6 @@
|
|||||||
target = 853F29612F4B50410092AD05 /* Rosetta */;
|
target = 853F29612F4B50410092AD05 /* Rosetta */;
|
||||||
targetProxy = D1E9D598009C8306B116CA87 /* PBXContainerItemProxy */;
|
targetProxy = D1E9D598009C8306B116CA87 /* PBXContainerItemProxy */;
|
||||||
};
|
};
|
||||||
|
|
||||||
LA000000A2F8D22220092AD05 /* PBXTargetDependency */ = {
|
LA000000A2F8D22220092AD05 /* PBXTargetDependency */ = {
|
||||||
isa = PBXTargetDependency;
|
isa = PBXTargetDependency;
|
||||||
target = LA00000012F8D22220092AD05 /* RosettaLiveActivityWidget */;
|
target = LA00000012F8D22220092AD05 /* RosettaLiveActivityWidget */;
|
||||||
@@ -761,7 +749,6 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
|
||||||
LA00000072F8D22220092AD05 /* Debug */ = {
|
LA00000072F8D22220092AD05 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
@@ -855,7 +842,6 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Debug;
|
defaultConfigurationName = Debug;
|
||||||
};
|
};
|
||||||
|
|
||||||
LA00000062F8D22220092AD05 /* Build configuration list for PBXNativeTarget "RosettaLiveActivityWidget" */ = {
|
LA00000062F8D22220092AD05 /* Build configuration list for PBXNativeTarget "RosettaLiveActivityWidget" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import AVFAudio
|
import AVFAudio
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
import WebRTC
|
import WebRTC
|
||||||
|
|
||||||
@@ -148,9 +149,12 @@ extension CallManager {
|
|||||||
remoteDescriptionSet = false
|
remoteDescriptionSet = false
|
||||||
lastPeerSharedPublicHex = ""
|
lastPeerSharedPublicHex = ""
|
||||||
|
|
||||||
uiState = CallUiState()
|
var finalState = CallUiState()
|
||||||
if let reason, !reason.isEmpty {
|
if let reason, !reason.isEmpty {
|
||||||
uiState.statusText = reason
|
finalState.statusText = reason
|
||||||
|
}
|
||||||
|
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
|
||||||
|
uiState = finalState
|
||||||
}
|
}
|
||||||
|
|
||||||
deactivateAudioSession()
|
deactivateAudioSession()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import AVFAudio
|
|||||||
import Combine
|
import Combine
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
import WebRTC
|
import WebRTC
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -143,6 +144,20 @@ final class CallManager: NSObject, ObservableObject {
|
|||||||
print("[Call] toggleSpeaker: isSpeakerOn=\(nextSpeaker), outputs=\(route.outputs.map { $0.portName })")
|
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
|
// MARK: - Protocol handlers
|
||||||
|
|
||||||
private func wireProtocolHandlers() {
|
private func wireProtocolHandlers() {
|
||||||
@@ -199,6 +214,9 @@ final class CallManager: NSObject, ObservableObject {
|
|||||||
beginCallSession(peerPublicKey: incomingPeer, title: "", username: "")
|
beginCallSession(peerPublicKey: incomingPeer, title: "", username: "")
|
||||||
role = .callee
|
role = .callee
|
||||||
uiState.phase = .incoming
|
uiState.phase = .incoming
|
||||||
|
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
|
||||||
|
uiState.isMinimized = false
|
||||||
|
}
|
||||||
uiState.statusText = "Incoming call..."
|
uiState.statusText = "Incoming call..."
|
||||||
hydratePeerIdentity(for: incomingPeer)
|
hydratePeerIdentity(for: incomingPeer)
|
||||||
CallSoundManager.shared.playRingtone()
|
CallSoundManager.shared.playRingtone()
|
||||||
|
|||||||
@@ -33,11 +33,17 @@ struct CallUiState: Equatable, Sendable {
|
|||||||
var isMuted: Bool = false
|
var isMuted: Bool = false
|
||||||
var isSpeakerOn: Bool = false
|
var isSpeakerOn: Bool = false
|
||||||
var keyCast: String = ""
|
var keyCast: String = ""
|
||||||
|
var isMinimized: Bool = false
|
||||||
|
|
||||||
var isVisible: Bool {
|
var isVisible: Bool {
|
||||||
phase != .idle
|
phase != .idle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Full-screen overlay should show when call is active AND not minimized.
|
||||||
|
var isFullScreenVisible: Bool {
|
||||||
|
isVisible && !isMinimized
|
||||||
|
}
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
if !peerTitle.isEmpty { return peerTitle }
|
if !peerTitle.isEmpty { return peerTitle }
|
||||||
if !peerUsername.isEmpty { return "@\(peerUsername)" }
|
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
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Full-Screen Call Overlay (Telegram-style)
|
||||||
|
|
||||||
struct ActiveCallOverlayView: View {
|
struct ActiveCallOverlayView: View {
|
||||||
@ObservedObject var callManager: CallManager
|
@ObservedObject var callManager: CallManager
|
||||||
|
@State private var peerAvatar: UIImage?
|
||||||
|
@State private var dragOffset: CGFloat = 0
|
||||||
|
|
||||||
private var state: CallUiState {
|
private var state: CallUiState {
|
||||||
callManager.uiState
|
callManager.uiState
|
||||||
@@ -11,11 +15,14 @@ struct ActiveCallOverlayView: View {
|
|||||||
let duration = max(state.durationSec, 0)
|
let duration = max(state.durationSec, 0)
|
||||||
let minutes = duration / 60
|
let minutes = duration / 60
|
||||||
let seconds = duration % 60
|
let seconds = duration % 60
|
||||||
return String(format: "%02d:%02d", minutes, seconds)
|
return String(format: "%d:%02d", minutes, seconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var peerInitials: String {
|
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 {
|
private var peerColorIndex: Int {
|
||||||
@@ -24,134 +31,162 @@ struct ActiveCallOverlayView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.black.opacity(0.7)
|
// Animated gradient background
|
||||||
.ignoresSafeArea()
|
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
|
avatarSection
|
||||||
|
|
||||||
|
// Name (Telegram: 28pt regular)
|
||||||
Text(state.displayName)
|
Text(state.displayName)
|
||||||
.font(.system(size: 22, weight: .semibold))
|
.font(.system(size: 28, weight: .regular))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 39)
|
||||||
|
|
||||||
if state.phase == .active {
|
// Status / Duration (Telegram: 15pt)
|
||||||
Text(durationText)
|
statusSection
|
||||||
.font(.system(size: 17, weight: .medium))
|
.padding(.top, 6)
|
||||||
.foregroundStyle(Color.white.opacity(0.85))
|
|
||||||
} else {
|
|
||||||
Text(statusText(for: state.phase))
|
|
||||||
.font(.system(size: 17, weight: .medium))
|
|
||||||
.foregroundStyle(Color.white.opacity(0.85))
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
private var avatarSection: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
if state.phase != .active {
|
if state.phase != .active {
|
||||||
PulsingRings()
|
CallPulsingRings(size: 136)
|
||||||
}
|
}
|
||||||
|
|
||||||
AvatarView(
|
AvatarView(
|
||||||
initials: peerInitials,
|
initials: peerInitials,
|
||||||
colorIndex: peerColorIndex,
|
colorIndex: peerColorIndex,
|
||||||
size: 90,
|
size: 136,
|
||||||
image: AvatarRepository.shared.loadAvatar(publicKey: state.peerPublicKey)
|
image: peerAvatar
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.frame(width: 130, height: 130)
|
.frame(width: 250, height: 250)
|
||||||
}
|
.task(id: state.peerPublicKey) {
|
||||||
|
peerAvatar = AvatarRepository.shared.loadAvatar(publicKey: state.peerPublicKey)
|
||||||
@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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
// MARK: - Status
|
||||||
private func callActionButton(
|
|
||||||
title: String,
|
private var statusSection: some View {
|
||||||
icon: String,
|
Group {
|
||||||
color: Color,
|
if state.phase == .active {
|
||||||
action: @escaping () -> Void
|
Text(durationText)
|
||||||
) -> some View {
|
.font(.system(size: 15, weight: .regular).monospacedDigit())
|
||||||
Button(action: action) {
|
.contentTransition(.numericText())
|
||||||
VStack(spacing: 8) {
|
} else {
|
||||||
Image(systemName: icon)
|
Text(statusText(for: state.phase))
|
||||||
.font(.system(size: 20, weight: .semibold))
|
.font(.system(size: 15, weight: .regular))
|
||||||
.foregroundStyle(.white)
|
|
||||||
.frame(width: 52, height: 52)
|
|
||||||
.background(Circle().fill(color))
|
|
||||||
Text(title)
|
|
||||||
.font(.system(size: 13, weight: .medium))
|
|
||||||
.foregroundStyle(.white.opacity(0.92))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.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 {
|
private func statusText(for phase: CallPhase) -> String {
|
||||||
switch phase {
|
switch phase {
|
||||||
case .incoming: return "Incoming call"
|
case .incoming: return "Incoming call"
|
||||||
case .outgoing: return "Calling..."
|
case .outgoing: return "Ringing..."
|
||||||
case .keyExchange: return "Exchanging keys..."
|
case .keyExchange: return "Exchanging keys..."
|
||||||
case .webRtcExchange: return "Connecting..."
|
case .webRtcExchange: return "Connecting..."
|
||||||
case .active: return "Active"
|
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 SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// Action model for context menu buttons.
|
/// Transparent overlay that triggers a Telegram-style context menu on long press.
|
||||||
struct BubbleContextAction {
|
|
||||||
let title: String
|
|
||||||
let image: UIImage?
|
|
||||||
let role: UIMenuElement.Attributes
|
|
||||||
let handler: () -> Void
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Transparent overlay that attaches UIContextMenuInteraction to a message bubble.
|
|
||||||
///
|
///
|
||||||
/// Uses a **window snapshot** approach instead of UIHostingController preview:
|
/// Also supports single-tap routing (image viewer, file download, reply quote tap)
|
||||||
/// 1. On long-press, captures a pixel-perfect screenshot of the bubble from the window
|
/// because the overlay UIView intercepts all touch events, preventing SwiftUI
|
||||||
/// 2. Uses this snapshot as `UITargetedPreview` with `previewProvider: nil`
|
/// `onTapGesture` on content below from firing.
|
||||||
/// 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.
|
|
||||||
struct BubbleContextMenuOverlay: UIViewRepresentable {
|
struct BubbleContextMenuOverlay: UIViewRepresentable {
|
||||||
let actions: [BubbleContextAction]
|
let items: [TelegramContextMenuItem]
|
||||||
let previewShape: MessageBubbleShape
|
let previewShape: MessageBubbleShape
|
||||||
let readStatusText: String?
|
let isOutgoing: Bool
|
||||||
|
|
||||||
/// Called when user single-taps the bubble. Receives tap location in the overlay's
|
/// 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).
|
/// 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 {
|
func makeUIView(context: Context) -> UIView {
|
||||||
let view = UIView()
|
let view = UIView()
|
||||||
view.backgroundColor = .clear
|
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).
|
// 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(_:)))
|
let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:)))
|
||||||
view.addGestureRecognizer(tap)
|
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
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ uiView: UIView, context: Context) {
|
func updateUIView(_ uiView: UIView, context: Context) {
|
||||||
// PERF: only update callbacks (lightweight pointer swap).
|
context.coordinator.items = items
|
||||||
// Skip actions/previewShape/readStatusText — these involve array allocation
|
context.coordinator.previewShape = previewShape
|
||||||
// and struct copying on EVERY layout pass (40× cells × 8 keyboard ticks = 320/s).
|
context.coordinator.isOutgoing = isOutgoing
|
||||||
// Context menu will use stale actions until cell is recycled — acceptable trade-off.
|
|
||||||
context.coordinator.onTap = onTap
|
context.coordinator.onTap = onTap
|
||||||
|
context.coordinator.replyQuoteHeight = replyQuoteHeight
|
||||||
context.coordinator.onReplyQuoteTap = onReplyQuoteTap
|
context.coordinator.onReplyQuoteTap = onReplyQuoteTap
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator { Coordinator(overlay: self) }
|
func makeCoordinator() -> Coordinator { Coordinator(overlay: self) }
|
||||||
|
|
||||||
final class Coordinator: NSObject, UIContextMenuInteractionDelegate {
|
final class Coordinator: NSObject {
|
||||||
var actions: [BubbleContextAction]
|
var items: [TelegramContextMenuItem]
|
||||||
var previewShape: MessageBubbleShape
|
var previewShape: MessageBubbleShape
|
||||||
var readStatusText: String?
|
var isOutgoing: Bool
|
||||||
var onTap: ((CGPoint) -> Void)?
|
var onTap: ((CGPoint) -> Void)?
|
||||||
var replyQuoteHeight: CGFloat = 0
|
var replyQuoteHeight: CGFloat = 0
|
||||||
var onReplyQuoteTap: (() -> Void)?
|
var onReplyQuoteTap: (() -> Void)?
|
||||||
private var snapshotView: UIImageView?
|
private let haptic = UIImpactFeedbackGenerator(style: .medium)
|
||||||
|
|
||||||
init(overlay: BubbleContextMenuOverlay) {
|
init(overlay: BubbleContextMenuOverlay) {
|
||||||
self.actions = overlay.actions
|
self.items = overlay.items
|
||||||
self.previewShape = overlay.previewShape
|
self.previewShape = overlay.previewShape
|
||||||
self.readStatusText = overlay.readStatusText
|
self.isOutgoing = overlay.isOutgoing
|
||||||
self.onTap = overlay.onTap
|
self.onTap = overlay.onTap
|
||||||
self.replyQuoteHeight = overlay.replyQuoteHeight
|
self.replyQuoteHeight = overlay.replyQuoteHeight
|
||||||
self.onReplyQuoteTap = overlay.onReplyQuoteTap
|
self.onReplyQuoteTap = overlay.onReplyQuoteTap
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Single Tap
|
||||||
|
|
||||||
@objc func handleTap(_ recognizer: UITapGestureRecognizer) {
|
@objc func handleTap(_ recognizer: UITapGestureRecognizer) {
|
||||||
// Route taps in the reply quote region to the reply handler.
|
// Route taps in the reply quote region to the reply handler.
|
||||||
if replyQuoteHeight > 0, let view = recognizer.view {
|
if replyQuoteHeight > 0, let view = recognizer.view {
|
||||||
@@ -93,100 +86,32 @@ struct BubbleContextMenuOverlay: UIViewRepresentable {
|
|||||||
onTap?(location)
|
onTap?(location)
|
||||||
}
|
}
|
||||||
|
|
||||||
func contextMenuInteraction(
|
// MARK: - Long Press → Context Menu
|
||||||
_ interaction: UIContextMenuInteraction,
|
|
||||||
configurationForMenuAtLocation location: CGPoint
|
|
||||||
) -> UIContextMenuConfiguration? {
|
|
||||||
captureSnapshot(for: interaction)
|
|
||||||
|
|
||||||
return UIContextMenuConfiguration(
|
@objc func handleLongPress(_ recognizer: UILongPressGestureRecognizer) {
|
||||||
identifier: nil,
|
guard recognizer.state == .began else { return }
|
||||||
previewProvider: nil,
|
haptic.impactOccurred()
|
||||||
actionProvider: { [weak self] _ in
|
presentMenu(from: recognizer.view)
|
||||||
self?.buildMenu()
|
}
|
||||||
}
|
|
||||||
|
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
|
// MARK: - Reply Bar
|
||||||
|
|
||||||
/// Extract reply preview text for ComposerView (same logic as SwiftUI replyBar).
|
/// 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 {
|
func replyPreviewText(for message: ChatMessage) -> String {
|
||||||
if message.attachments.contains(where: { $0.type == .image }) {
|
// 1. Determine attachment type label
|
||||||
let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
let attachmentLabel: String? = {
|
||||||
return caption.isEmpty ? "Photo" : caption
|
for att in message.attachments {
|
||||||
}
|
switch att.type {
|
||||||
if let file = message.attachments.first(where: { $0.type == .file }) {
|
case .image: return "Photo"
|
||||||
let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
case .file:
|
||||||
if !caption.isEmpty { return caption }
|
let parsed = AttachmentPreviewCodec.parseFilePreview(att.preview)
|
||||||
let parsed = AttachmentPreviewCodec.parseFilePreview(file.preview)
|
if !parsed.fileName.isEmpty { return parsed.fileName }
|
||||||
if !parsed.fileName.isEmpty { return parsed.fileName }
|
return att.id.isEmpty ? "File" : att.id
|
||||||
return file.id.isEmpty ? "File" : file.id
|
case .avatar: return "Avatar"
|
||||||
}
|
case .messages: return "Forwarded message"
|
||||||
if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" }
|
case .call: return "Call"
|
||||||
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" }
|
return nil
|
||||||
let trimmed = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
}()
|
||||||
if !trimmed.isEmpty { return message.text }
|
|
||||||
if !message.attachments.isEmpty { return "Attachment" }
|
// 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 ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func replyBar(for message: ChatMessage) -> some View {
|
func replyBar(for message: ChatMessage) -> some View {
|
||||||
let senderName = senderDisplayName(for: message.fromPublicKey)
|
let senderName = senderDisplayName(for: message.fromPublicKey)
|
||||||
let previewText: String = {
|
let previewText = replyPreviewText(for: message)
|
||||||
// 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
|
|
||||||
|
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
RoundedRectangle(cornerRadius: 1.0)
|
RoundedRectangle(cornerRadius: 1.0)
|
||||||
|
|||||||
@@ -235,12 +235,18 @@ final class ComposerView: UIView, UITextViewDelegate {
|
|||||||
|
|
||||||
func setReply(senderName: String?, previewText: String?) {
|
func setReply(senderName: String?, previewText: String?) {
|
||||||
let shouldShow = senderName != nil
|
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 }
|
guard shouldShow != isReplyVisible else { return }
|
||||||
isReplyVisible = shouldShow
|
isReplyVisible = shouldShow
|
||||||
|
|
||||||
if shouldShow {
|
if shouldShow {
|
||||||
replySenderLabel.text = senderName
|
|
||||||
replyPreviewLabel.text = previewText ?? ""
|
|
||||||
replyBar.isHidden = false
|
replyBar.isHidden = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,9 +122,9 @@ struct MessageCellView: View, Equatable {
|
|||||||
.background { bubbleBackground(outgoing: outgoing, position: position) }
|
.background { bubbleBackground(outgoing: outgoing, position: position) }
|
||||||
.overlay {
|
.overlay {
|
||||||
BubbleContextMenuOverlay(
|
BubbleContextMenuOverlay(
|
||||||
actions: bubbleActions(for: message),
|
items: contextMenuItems(for: message),
|
||||||
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
|
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
|
||||||
readStatusText: contextMenuReadStatus(for: message),
|
isOutgoing: outgoing,
|
||||||
replyQuoteHeight: replyData != nil ? 46 : 0,
|
replyQuoteHeight: replyData != nil ? 46 : 0,
|
||||||
onReplyQuoteTap: replyData.map { reply in
|
onReplyQuoteTap: replyData.map { reply in
|
||||||
{ [reply] in actions.onScrollToMessage(reply.message_id) }
|
{ [reply] in actions.onScrollToMessage(reply.message_id) }
|
||||||
@@ -262,9 +262,9 @@ struct MessageCellView: View, Equatable {
|
|||||||
.background { bubbleBackground(outgoing: outgoing, position: position) }
|
.background { bubbleBackground(outgoing: outgoing, position: position) }
|
||||||
.overlay {
|
.overlay {
|
||||||
BubbleContextMenuOverlay(
|
BubbleContextMenuOverlay(
|
||||||
actions: bubbleActions(for: message),
|
items: contextMenuItems(for: message),
|
||||||
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
|
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
|
||||||
readStatusText: contextMenuReadStatus(for: message),
|
isOutgoing: outgoing,
|
||||||
onTap: !imageAttachments.isEmpty ? { _ in
|
onTap: !imageAttachments.isEmpty ? { _ in
|
||||||
if let firstId = imageAttachments.first?.id {
|
if let firstId = imageAttachments.first?.id {
|
||||||
actions.onImageTap(firstId)
|
actions.onImageTap(firstId)
|
||||||
@@ -362,9 +362,9 @@ struct MessageCellView: View, Equatable {
|
|||||||
.clipShape(MessageBubbleShape(position: position, outgoing: outgoing))
|
.clipShape(MessageBubbleShape(position: position, outgoing: outgoing))
|
||||||
.overlay {
|
.overlay {
|
||||||
BubbleContextMenuOverlay(
|
BubbleContextMenuOverlay(
|
||||||
actions: bubbleActions(for: message),
|
items: contextMenuItems(for: message),
|
||||||
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
|
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
|
||||||
readStatusText: contextMenuReadStatus(for: message),
|
isOutgoing: outgoing,
|
||||||
onTap: !attachments.isEmpty ? { tapLocation in
|
onTap: !attachments.isEmpty ? { tapLocation in
|
||||||
if !imageAttachments.isEmpty {
|
if !imageAttachments.isEmpty {
|
||||||
let tappedId = imageAttachments.count == 1
|
let tappedId = imageAttachments.count == 1
|
||||||
@@ -536,48 +536,13 @@ struct MessageCellView: View, Equatable {
|
|||||||
|
|
||||||
// MARK: - Context Menu
|
// MARK: - Context Menu
|
||||||
|
|
||||||
private func contextMenuReadStatus(for message: ChatMessage) -> String? {
|
private func contextMenuItems(for message: ChatMessage) -> [TelegramContextMenuItem] {
|
||||||
let outgoing = message.isFromMe(myPublicKey: currentPublicKey)
|
TelegramContextMenuBuilder.menuItems(
|
||||||
guard outgoing, message.deliveryStatus == .delivered, message.isRead else { return nil }
|
for: message,
|
||||||
return "Read"
|
actions: actions,
|
||||||
}
|
isSavedMessages: isSavedMessages,
|
||||||
|
isSystemAccount: isSystemAccount
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Reply Quote
|
// MARK: - Reply Quote
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import UIKit
|
|||||||
/// 3. No SwiftUI, no UIHostingConfiguration, no self-sizing
|
/// 3. No SwiftUI, no UIHostingConfiguration, no self-sizing
|
||||||
///
|
///
|
||||||
/// Subviews are always present but hidden when not needed (no alloc/dealloc overhead).
|
/// Subviews are always present but hidden when not needed (no alloc/dealloc overhead).
|
||||||
final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDelegate {
|
final class NativeMessageCell: UICollectionViewCell {
|
||||||
|
|
||||||
// MARK: - Constants
|
// MARK: - Constants
|
||||||
|
|
||||||
@@ -400,9 +400,10 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
deliveryFailedButton.addTarget(self, action: #selector(handleDeliveryFailedTap), for: .touchUpInside)
|
deliveryFailedButton.addTarget(self, action: #selector(handleDeliveryFailedTap), for: .touchUpInside)
|
||||||
contentView.addSubview(deliveryFailedButton)
|
contentView.addSubview(deliveryFailedButton)
|
||||||
|
|
||||||
// Interactions
|
// Long-press → Telegram context menu
|
||||||
let contextMenu = UIContextMenuInteraction(delegate: self)
|
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
|
||||||
bubbleView.addInteraction(contextMenu)
|
longPress.minimumPressDuration = 0.35
|
||||||
|
bubbleView.addGestureRecognizer(longPress)
|
||||||
|
|
||||||
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleSwipe(_:)))
|
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleSwipe(_:)))
|
||||||
pan.delegate = self
|
pan.delegate = self
|
||||||
@@ -527,6 +528,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
|
|
||||||
// Title (16pt medium — Telegram parity)
|
// Title (16pt medium — Telegram parity)
|
||||||
fileNameLabel.font = UIFont.systemFont(ofSize: 16, weight: .medium)
|
fileNameLabel.font = UIFont.systemFont(ofSize: 16, weight: .medium)
|
||||||
|
fileNameLabel.textColor = .white
|
||||||
if isMissed {
|
if isMissed {
|
||||||
fileNameLabel.text = isIncoming ? "Missed Call" : "Cancelled Call"
|
fileNameLabel.text = isIncoming ? "Missed Call" : "Cancelled Call"
|
||||||
} else {
|
} else {
|
||||||
@@ -998,36 +1000,45 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
return attrs
|
return attrs
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Context Menu
|
// MARK: - Context Menu (Telegram-style)
|
||||||
|
|
||||||
func contextMenuInteraction(
|
private let contextMenuHaptic = UIImpactFeedbackGenerator(style: .medium)
|
||||||
_ interaction: UIContextMenuInteraction,
|
|
||||||
configurationForMenuAtLocation location: CGPoint
|
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
|
||||||
) -> UIContextMenuConfiguration? {
|
guard gesture.state == .began else { return }
|
||||||
guard let message, let actions else { return nil }
|
contextMenuHaptic.impactOccurred()
|
||||||
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in
|
presentContextMenu()
|
||||||
var items: [UIAction] = []
|
}
|
||||||
if !message.text.isEmpty {
|
|
||||||
items.append(UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc")) { _ in
|
private func presentContextMenu() {
|
||||||
actions.onCopy(message.text)
|
guard let message, let actions else { return }
|
||||||
})
|
guard let layout = currentLayout else { return }
|
||||||
}
|
|
||||||
// Desktop parity: system accounts + ATTACHMENTS_NOT_ALLOWED_TO_REPLY = [AVATAR, MESSAGES]
|
// Capture snapshot from window (pixel-perfect, accounts for inverted scroll)
|
||||||
let isAvatarOrForwarded = message.attachments.contains(where: { $0.type == .avatar || $0.type == .messages })
|
guard let (snapshot, frame) = TelegramContextMenuController.captureSnapshot(of: bubbleView) else { return }
|
||||||
let canReplyForward = !self.isSavedMessages && !self.isSystemAccount && !isAvatarOrForwarded
|
|
||||||
if canReplyForward {
|
// Build bubble mask path
|
||||||
items.append(UIAction(title: "Reply", image: UIImage(systemName: "arrowshape.turn.up.left")) { _ in
|
let bubblePath = BubbleGeometryEngine.makeBezierPath(
|
||||||
actions.onReply(message)
|
in: CGRect(origin: .zero, size: frame.size),
|
||||||
})
|
mergeType: layout.mergeType,
|
||||||
items.append(UIAction(title: "Forward", image: UIImage(systemName: "arrowshape.turn.up.right")) { _ in
|
outgoing: layout.isOutgoing
|
||||||
actions.onForward(message)
|
)
|
||||||
})
|
|
||||||
}
|
// Build menu items
|
||||||
items.append(UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { _ in
|
let items = TelegramContextMenuBuilder.menuItems(
|
||||||
actions.onDelete(message)
|
for: message,
|
||||||
})
|
actions: actions,
|
||||||
return UIMenu(children: items)
|
isSavedMessages: isSavedMessages,
|
||||||
}
|
isSystemAccount: isSystemAccount
|
||||||
|
)
|
||||||
|
|
||||||
|
TelegramContextMenuController.present(
|
||||||
|
snapshot: snapshot,
|
||||||
|
sourceFrame: frame,
|
||||||
|
bubblePath: bubblePath,
|
||||||
|
items: items,
|
||||||
|
isOutgoing: layout.isOutgoing
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Swipe to Reply
|
// MARK: - Swipe to Reply
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ final class NativeMessageListController: UIViewController {
|
|||||||
static let messageToComposerGap: CGFloat = 16
|
static let messageToComposerGap: CGFloat = 16
|
||||||
static let scrollButtonSize: CGFloat = 40
|
static let scrollButtonSize: CGFloat = 40
|
||||||
static let scrollButtonIconCanvas: CGFloat = 38
|
static let scrollButtonIconCanvas: CGFloat = 38
|
||||||
static let scrollButtonBaseTrailing: CGFloat = 8
|
static let scrollButtonBaseTrailing: CGFloat = 17
|
||||||
static let scrollButtonCompactExtraTrailing: CGFloat = 18
|
static let scrollButtonCompactExtraTrailing: CGFloat = 18
|
||||||
static let scrollButtonBottomOffset: CGFloat = 20
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Configuration
|
// MARK: - Configuration
|
||||||
@@ -363,7 +363,7 @@ final class NativeMessageListController: UIViewController {
|
|||||||
)
|
)
|
||||||
let bottom = container.bottomAnchor.constraint(
|
let bottom = container.bottomAnchor.constraint(
|
||||||
equalTo: view.keyboardLayoutGuide.topAnchor,
|
equalTo: view.keyboardLayoutGuide.topAnchor,
|
||||||
constant: -(lastComposerHeight + UIConstants.scrollButtonBottomOffset)
|
constant: -(lastComposerHeight + 4)
|
||||||
)
|
)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
container.widthAnchor.constraint(equalToConstant: size),
|
container.widthAnchor.constraint(equalToConstant: size),
|
||||||
@@ -448,7 +448,7 @@ final class NativeMessageListController: UIViewController {
|
|||||||
let safeBottom = view.safeAreaInsets.bottom
|
let safeBottom = view.safeAreaInsets.bottom
|
||||||
let compactShift = safeBottom <= 32 ? UIConstants.scrollButtonCompactExtraTrailing : 0
|
let compactShift = safeBottom <= 32 ? UIConstants.scrollButtonCompactExtraTrailing : 0
|
||||||
scrollToBottomTrailingConstraint?.constant = -(UIConstants.scrollButtonBaseTrailing + compactShift)
|
scrollToBottomTrailingConstraint?.constant = -(UIConstants.scrollButtonBaseTrailing + compactShift)
|
||||||
scrollToBottomBottomConstraint?.constant = -(lastComposerHeight + UIConstants.scrollButtonBottomOffset)
|
scrollToBottomBottomConstraint?.constant = -(lastComposerHeight + 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateScrollToBottomBadge() {
|
private func updateScrollToBottomBadge() {
|
||||||
@@ -491,10 +491,12 @@ final class NativeMessageListController: UIViewController {
|
|||||||
gc.setLineCap(.round)
|
gc.setLineCap(.round)
|
||||||
gc.setLineJoin(.round)
|
gc.setLineJoin(.round)
|
||||||
|
|
||||||
let position = CGPoint(x: 9.0 - 0.5, y: 23.0)
|
// Down chevron (v), centered in canvas — Telegram parity.
|
||||||
gc.move(to: CGPoint(x: position.x + 1.0, y: position.y - 1.0))
|
let cx = size.width / 2
|
||||||
gc.addLine(to: CGPoint(x: position.x + 10.0, y: position.y - 10.0))
|
let cy = size.height / 2
|
||||||
gc.addLine(to: CGPoint(x: position.x + 19.0, y: position.y - 1.0))
|
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()
|
gc.strokePath()
|
||||||
}.withRenderingMode(.alwaysOriginal)
|
}.withRenderingMode(.alwaysOriginal)
|
||||||
}
|
}
|
||||||
@@ -1048,6 +1050,11 @@ struct NativeMessageListView: UIViewControllerRepresentable {
|
|||||||
private func syncComposerState(_ controller: NativeMessageListController) {
|
private func syncComposerState(_ controller: NativeMessageListController) {
|
||||||
guard let composer = controller.composerView else { return }
|
guard let composer = controller.composerView else { return }
|
||||||
composer.setText(messageText)
|
composer.setText(messageText)
|
||||||
|
#if DEBUG
|
||||||
|
if replySenderName != nil {
|
||||||
|
print("📋 syncComposer: sender=\(replySenderName ?? "nil") preview=\(replyPreviewText ?? "nil")")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
composer.setReply(senderName: replySenderName, previewText: replyPreviewText)
|
composer.setReply(senderName: replySenderName, previewText: replyPreviewText)
|
||||||
composer.setFocused(isInputFocused)
|
composer.setFocused(isInputFocused)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import UIKit
|
|||||||
/// Pure UIKit message cell for text messages (with optional reply quote).
|
/// Pure UIKit message cell for text messages (with optional reply quote).
|
||||||
/// Replaces UIHostingConfiguration + SwiftUI for the most common message type.
|
/// Replaces UIHostingConfiguration + SwiftUI for the most common message type.
|
||||||
/// Features: Figma-accurate bubble tail, context menu, swipe-to-reply, reply quote.
|
/// Features: Figma-accurate bubble tail, context menu, swipe-to-reply, reply quote.
|
||||||
final class NativeTextBubbleCell: UICollectionViewCell, UIContextMenuInteractionDelegate {
|
final class NativeTextBubbleCell: UICollectionViewCell {
|
||||||
|
|
||||||
// MARK: - Constants
|
// MARK: - Constants
|
||||||
|
|
||||||
@@ -110,9 +110,10 @@ final class NativeTextBubbleCell: UICollectionViewCell, UIContextMenuInteraction
|
|||||||
replyIconView.alpha = 0
|
replyIconView.alpha = 0
|
||||||
contentView.addSubview(replyIconView)
|
contentView.addSubview(replyIconView)
|
||||||
|
|
||||||
// Context menu
|
// Long-press → Telegram context menu
|
||||||
let contextMenu = UIContextMenuInteraction(delegate: self)
|
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
|
||||||
bubbleView.addInteraction(contextMenu)
|
longPress.minimumPressDuration = 0.35
|
||||||
|
bubbleView.addGestureRecognizer(longPress)
|
||||||
|
|
||||||
// Swipe-to-reply gesture
|
// Swipe-to-reply gesture
|
||||||
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleSwipe(_:)))
|
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(
|
private let contextMenuHaptic = UIImpactFeedbackGenerator(style: .medium)
|
||||||
_ interaction: UIContextMenuInteraction,
|
|
||||||
configurationForMenuAtLocation location: CGPoint
|
|
||||||
) -> UIContextMenuConfiguration? {
|
|
||||||
guard let message, let actions else { return nil }
|
|
||||||
|
|
||||||
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in
|
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
|
||||||
var items: [UIAction] = []
|
guard gesture.state == .began else { return }
|
||||||
|
contextMenuHaptic.impactOccurred()
|
||||||
|
presentContextMenu()
|
||||||
|
}
|
||||||
|
|
||||||
items.append(UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc")) { _ in
|
private func presentContextMenu() {
|
||||||
actions.onCopy(message.text)
|
guard let message, let actions else { return }
|
||||||
})
|
|
||||||
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)
|
// 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
|
// 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.
|
// never observes the dialogs dictionary directly.
|
||||||
UnreadCountObserver(count: $cachedUnreadCount)
|
UnreadCountObserver(count: $cachedUnreadCount)
|
||||||
|
|
||||||
if callManager.uiState.isVisible {
|
// Full-screen call overlay
|
||||||
ActiveCallOverlayView(callManager: callManager)
|
// Animation driven by withAnimation in CallManager methods —
|
||||||
.zIndex(10)
|
// 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.
|
// Switch to Chats tab when user taps a push notification.
|
||||||
@@ -195,16 +209,26 @@ struct MainTabView: View {
|
|||||||
isSearchActive: $isChatSearchActive,
|
isSearchActive: $isChatSearchActive,
|
||||||
isDetailPresented: $isChatListDetailPresented
|
isDetailPresented: $isChatListDetailPresented
|
||||||
)
|
)
|
||||||
|
.callBarSafeAreaInset(callBarTopPadding)
|
||||||
case .calls:
|
case .calls:
|
||||||
CallsView()
|
CallsView()
|
||||||
|
.callBarSafeAreaInset(callBarTopPadding)
|
||||||
case .settings:
|
case .settings:
|
||||||
SettingsView(onLogout: onLogout, onAddAccount: handleAddAccount, isEditingProfile: $isSettingsEditPresented, isDetailPresented: $isSettingsDetailPresented)
|
SettingsView(onLogout: onLogout, onAddAccount: handleAddAccount, isEditingProfile: $isSettingsEditPresented, isDetailPresented: $isSettingsDetailPresented)
|
||||||
|
.callBarSafeAreaInset(callBarTopPadding)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
RosettaColors.Adaptive.background
|
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 {
|
private var isAnyChatDetailPresented: Bool {
|
||||||
isChatListDetailPresented
|
isChatListDetailPresented
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user