Push-уведомления: Telegram-parity in-app баннер, threadIdentifier группировка и letter-avatar в NSE
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,6 +12,7 @@ Telegram-iOS
|
||||
AGENTS.md
|
||||
voip.p12
|
||||
CertificateSigningRequest.certSigningRequest
|
||||
PhotosTransition
|
||||
|
||||
# Xcode
|
||||
build/
|
||||
|
||||
@@ -82,8 +82,10 @@ final class CallKitManager: NSObject {
|
||||
Self.logger.info("Incoming call reported to CallKit (uuid=\(uuid.uuidString.prefix(8)))")
|
||||
}
|
||||
// Assign to MainActor-isolated property.
|
||||
// Guard: only assign if no newer UUID exists — WebSocket signal
|
||||
// may have already reported a different call by the time this runs.
|
||||
Task { @MainActor in
|
||||
if error == nil {
|
||||
if error == nil, self?.currentCallUUID == nil {
|
||||
self?.currentCallUUID = uuid
|
||||
}
|
||||
}
|
||||
@@ -209,8 +211,13 @@ extension CallKitManager: CXProviderDelegate {
|
||||
nonisolated func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
|
||||
Self.logger.info("CXAnswerCallAction")
|
||||
Task { @MainActor in
|
||||
CallManager.shared.acceptIncomingCall()
|
||||
action.fulfill()
|
||||
let result = CallManager.shared.acceptIncomingCall()
|
||||
if result == .started {
|
||||
action.fulfill()
|
||||
} else {
|
||||
Self.logger.warning("CXAnswerCallAction failed: \(String(describing: result))")
|
||||
action.fail()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,8 @@ extension CallManager {
|
||||
}
|
||||
|
||||
func ensurePeerConnectionAndOffer() async {
|
||||
// Guard: finishCall() may have run during the async gap before this Task executes.
|
||||
guard uiState.phase == .webRtcExchange else { return }
|
||||
do {
|
||||
try configureAudioSession()
|
||||
let peerConnection = try ensurePeerConnection()
|
||||
@@ -84,63 +86,21 @@ extension CallManager {
|
||||
|
||||
func finishCall(reason: String?, notifyPeer: Bool, skipAttachment: Bool = false) {
|
||||
// Guard: finishCall can be called twice when CXEndCallAction callback
|
||||
// re-enters via CallManager.endCall(). Skip if already idle.
|
||||
guard uiState.phase != .idle else { return }
|
||||
// re-enters via CallManager.endCall(). Skip if already idle or mid-finish.
|
||||
guard !isFinishingCall, uiState.phase != .idle else { return }
|
||||
isFinishingCall = true
|
||||
defer { isFinishingCall = false }
|
||||
|
||||
print("[CallBar] finishCall(reason=\(reason ?? "nil")) — phase=\(uiState.phase.rawValue) isMinimized=\(uiState.isMinimized)")
|
||||
// Log call stack to identify WHO triggered finishCall
|
||||
let symbols = Thread.callStackSymbols.prefix(8).joined(separator: "\n ")
|
||||
print("[CallBar] stack:\n \(symbols)")
|
||||
|
||||
// Report call ended to CallKit. Use reportCallEndedByRemote when we're not
|
||||
// the initiator of the end (avoids CXEndCallAction → endCall() loop).
|
||||
if notifyPeer {
|
||||
CallKitManager.shared.endCall()
|
||||
} else {
|
||||
CallKitManager.shared.reportCallEndedByRemote()
|
||||
}
|
||||
|
||||
pendingMinimizeTask?.cancel()
|
||||
pendingMinimizeTask = nil
|
||||
cancelRingTimeout()
|
||||
endLiveActivity()
|
||||
let wasActive = uiState.phase == .active
|
||||
if wasActive {
|
||||
CallSoundManager.shared.playEndCall()
|
||||
} else {
|
||||
CallSoundManager.shared.stopAll()
|
||||
}
|
||||
|
||||
let snapshot = uiState
|
||||
if notifyPeer,
|
||||
ownPublicKey.isEmpty == false,
|
||||
snapshot.peerPublicKey.isEmpty == false,
|
||||
snapshot.phase != .idle {
|
||||
ProtocolManager.shared.sendCallSignal(
|
||||
signalType: .endCall,
|
||||
src: ownPublicKey,
|
||||
dst: snapshot.peerPublicKey
|
||||
)
|
||||
}
|
||||
|
||||
// Skip call attachment for "busy" — call never connected, prevents chat flooding.
|
||||
if !skipAttachment,
|
||||
role == .caller,
|
||||
snapshot.peerPublicKey.isEmpty == false {
|
||||
let duration = max(snapshot.durationSec, 0)
|
||||
let peerKey = snapshot.peerPublicKey
|
||||
Task { @MainActor in
|
||||
try? await SessionManager.shared.sendCallAttachment(
|
||||
toPublicKey: peerKey,
|
||||
durationSec: duration,
|
||||
opponentTitle: snapshot.peerTitle,
|
||||
opponentUsername: snapshot.peerUsername
|
||||
)
|
||||
// Force immediate cache refresh so call attachment appears in chat
|
||||
MessageRepository.shared.refreshDialogCache(for: peerKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Close WebRTC FIRST — SFU sees peer disconnect immediately.
|
||||
// Without this, SFU waits for ICE timeout (~30s) before releasing the room,
|
||||
// blocking new calls to the same peer.
|
||||
durationTask?.cancel()
|
||||
durationTask = nil
|
||||
|
||||
@@ -161,6 +121,55 @@ extension CallManager {
|
||||
bufferedRemoteCandidates.removeAll()
|
||||
attachedReceiverIds.removeAll()
|
||||
|
||||
// Step 2: Report to CallKit.
|
||||
if notifyPeer {
|
||||
CallKitManager.shared.endCall()
|
||||
} else {
|
||||
CallKitManager.shared.reportCallEndedByRemote()
|
||||
}
|
||||
|
||||
// Step 3: Cancel timers, sounds, live activity.
|
||||
pendingMinimizeTask?.cancel()
|
||||
pendingMinimizeTask = nil
|
||||
cancelRingTimeout()
|
||||
endLiveActivity()
|
||||
let wasActive = snapshot.phase == .active
|
||||
if wasActive {
|
||||
CallSoundManager.shared.playEndCall()
|
||||
} else {
|
||||
CallSoundManager.shared.stopAll()
|
||||
}
|
||||
|
||||
// Step 4: Notify peer AFTER WebRTC is closed.
|
||||
if notifyPeer,
|
||||
ownPublicKey.isEmpty == false,
|
||||
snapshot.peerPublicKey.isEmpty == false,
|
||||
snapshot.phase != .idle {
|
||||
ProtocolManager.shared.sendCallSignal(
|
||||
signalType: .endCall,
|
||||
src: ownPublicKey,
|
||||
dst: snapshot.peerPublicKey
|
||||
)
|
||||
}
|
||||
|
||||
// Step 5: Send call attachment (async, non-blocking).
|
||||
if !skipAttachment,
|
||||
role == .caller,
|
||||
snapshot.peerPublicKey.isEmpty == false {
|
||||
let duration = max(snapshot.durationSec, 0)
|
||||
let peerKey = snapshot.peerPublicKey
|
||||
Task { @MainActor in
|
||||
try? await SessionManager.shared.sendCallAttachment(
|
||||
toPublicKey: peerKey,
|
||||
durationSec: duration,
|
||||
opponentTitle: snapshot.peerTitle,
|
||||
opponentUsername: snapshot.peerUsername
|
||||
)
|
||||
MessageRepository.shared.refreshDialogCache(for: peerKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Reset all state.
|
||||
role = nil
|
||||
roomId = ""
|
||||
localPrivateKey = nil
|
||||
|
||||
@@ -41,6 +41,8 @@ final class CallManager: NSObject, ObservableObject {
|
||||
var ringTimeoutTask: Task<Void, Never>?
|
||||
var pendingMinimizeTask: Task<Void, Never>?
|
||||
var liveActivity: Activity<CallActivityAttributes>?
|
||||
/// Re-entrancy guard: prevents CXEndCallAction → endCall() → finishCall() loop.
|
||||
var isFinishingCall = false
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
@@ -111,6 +113,7 @@ final class CallManager: NSObject, ObservableObject {
|
||||
)
|
||||
|
||||
uiState.phase = .keyExchange
|
||||
uiState.isMinimized = false // Show full-screen custom overlay after accept
|
||||
uiState.statusText = "Exchanging keys..."
|
||||
return .started
|
||||
}
|
||||
@@ -232,9 +235,9 @@ final class CallManager: NSObject, ObservableObject {
|
||||
}
|
||||
uiState.statusText = "Incoming call..."
|
||||
hydratePeerIdentity(for: incomingPeer)
|
||||
// Report to CallKit (skipped if already reported via VoIP push).
|
||||
// Use hasPendingCall() for thread-safe check — PushKit sets the UUID
|
||||
// synchronously before MainActor assigns currentCallUUID.
|
||||
// Always report to CallKit (foreground = compact banner, background = full screen).
|
||||
// Custom ActiveCallOverlayView only appears AFTER user accepts (phase != .incoming).
|
||||
// Telegram parity: CallKit handles incoming UI, custom UI handles active call.
|
||||
if CallKitManager.shared.currentCallUUID == nil,
|
||||
!CallKitManager.shared.hasPendingCall() {
|
||||
CallKitManager.shared.reportIncomingCall(
|
||||
@@ -242,9 +245,8 @@ final class CallManager: NSObject, ObservableObject {
|
||||
callerName: uiState.displayName
|
||||
)
|
||||
}
|
||||
CallSoundManager.shared.playRingtone()
|
||||
// No playRingtone / startLiveActivity — CallKit handles ringtone and Dynamic Island.
|
||||
startRingTimeout()
|
||||
startLiveActivity()
|
||||
case .keyExchange:
|
||||
handleKeyExchange(packet)
|
||||
case .createRoom:
|
||||
|
||||
143
Rosetta/Core/Services/InAppNotificationManager.swift
Normal file
143
Rosetta/Core/Services/InAppNotificationManager.swift
Normal file
@@ -0,0 +1,143 @@
|
||||
import AudioToolbox
|
||||
import AVFAudio
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
/// Manages in-app notification banners (Telegram parity).
|
||||
/// Shows custom overlay instead of system banners when app is in foreground.
|
||||
@MainActor
|
||||
final class InAppNotificationManager: ObservableObject {
|
||||
|
||||
static let shared = InAppNotificationManager()
|
||||
|
||||
@Published private(set) var currentNotification: InAppNotification?
|
||||
|
||||
private var dismissTask: Task<Void, Never>?
|
||||
private var soundPlayer: AVAudioPlayer?
|
||||
|
||||
private static let autoDismissSeconds: UInt64 = 5
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Data Model
|
||||
|
||||
struct InAppNotification: Identifiable, Equatable {
|
||||
let id: UUID
|
||||
let senderKey: String
|
||||
let senderName: String
|
||||
let messagePreview: String
|
||||
let avatar: UIImage?
|
||||
let avatarColorIndex: Int
|
||||
let initials: String
|
||||
|
||||
static func == (lhs: InAppNotification, rhs: InAppNotification) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Called from `willPresent` — extracts sender info and shows banner if appropriate.
|
||||
func show(userInfo: [AnyHashable: Any]) {
|
||||
let senderKey = userInfo["dialog"] as? String
|
||||
?? AppDelegate.extractSenderKey(from: userInfo)
|
||||
|
||||
// --- Suppression logic (Telegram parity) ---
|
||||
guard !senderKey.isEmpty else { return }
|
||||
guard !MessageRepository.shared.isDialogActive(senderKey) else { return }
|
||||
|
||||
let mutedKeys = UserDefaults(suiteName: "group.com.rosetta.dev")?
|
||||
.stringArray(forKey: "muted_chats_keys") ?? []
|
||||
guard !mutedKeys.contains(senderKey) else { return }
|
||||
|
||||
// --- Resolve display data ---
|
||||
let contactNames = UserDefaults(suiteName: "group.com.rosetta.dev")?
|
||||
.dictionary(forKey: "contact_display_names") as? [String: String] ?? [:]
|
||||
let name = contactNames[senderKey]
|
||||
?? firstString(userInfo, keys: ["title", "sender_name", "from_title", "name"])
|
||||
?? "Rosetta"
|
||||
|
||||
let preview = extractMessagePreview(from: userInfo)
|
||||
let avatar = AvatarRepository.shared.loadAvatar(publicKey: senderKey)
|
||||
let dialog = DialogRepository.shared.dialogs[senderKey]
|
||||
|
||||
let notification = InAppNotification(
|
||||
id: UUID(),
|
||||
senderKey: senderKey,
|
||||
senderName: name,
|
||||
messagePreview: preview,
|
||||
avatar: avatar,
|
||||
avatarColorIndex: dialog?.avatarColorIndex ?? abs(senderKey.hashValue) % 11,
|
||||
initials: dialog?.initials ?? String(name.prefix(1)).uppercased()
|
||||
)
|
||||
|
||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
|
||||
currentNotification = notification
|
||||
}
|
||||
|
||||
playNotificationSound()
|
||||
playHaptic()
|
||||
scheduleAutoDismiss()
|
||||
}
|
||||
|
||||
func dismiss() {
|
||||
dismissTask?.cancel()
|
||||
withAnimation(.easeOut(duration: 0.25)) {
|
||||
currentNotification = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Testable: checks whether a notification should be suppressed.
|
||||
static func shouldSuppress(senderKey: String) -> Bool {
|
||||
if senderKey.isEmpty { return true }
|
||||
if MessageRepository.shared.isDialogActive(senderKey) { return true }
|
||||
let mutedKeys = UserDefaults(suiteName: "group.com.rosetta.dev")?
|
||||
.stringArray(forKey: "muted_chats_keys") ?? []
|
||||
if mutedKeys.contains(senderKey) { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func scheduleAutoDismiss() {
|
||||
dismissTask?.cancel()
|
||||
dismissTask = Task {
|
||||
try? await Task.sleep(nanoseconds: Self.autoDismissSeconds * 1_000_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private func playNotificationSound() {
|
||||
// System "Tink" haptic feedback sound — lightweight, no custom mp3 needed.
|
||||
AudioServicesPlaySystemSound(1057)
|
||||
}
|
||||
|
||||
private func playHaptic() {
|
||||
// UIImpactFeedbackGenerator style tap via AudioServices.
|
||||
let generator = UIImpactFeedbackGenerator(style: .light)
|
||||
generator.impactOccurred()
|
||||
}
|
||||
|
||||
private func extractMessagePreview(from userInfo: [AnyHashable: Any]) -> String {
|
||||
// Try notification body directly.
|
||||
if let body = userInfo["body"] as? String, !body.isEmpty { return body }
|
||||
// Try aps.alert (can be string or dict).
|
||||
if let aps = userInfo["aps"] as? [String: Any] {
|
||||
if let alert = aps["alert"] as? String, !alert.isEmpty { return alert }
|
||||
if let alertDict = aps["alert"] as? [String: Any],
|
||||
let body = alertDict["body"] as? String, !body.isEmpty { return body }
|
||||
}
|
||||
return "New message"
|
||||
}
|
||||
|
||||
private func firstString(_ dict: [AnyHashable: Any], keys: [String]) -> String? {
|
||||
for key in keys {
|
||||
if let value = dict[key] as? String,
|
||||
!value.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Telegram-style in-app notification banner with glass background.
|
||||
/// Shows sender avatar, name, and message preview. Supports swipe-up
|
||||
/// to dismiss and tap to navigate to the chat.
|
||||
struct InAppNotificationBanner: View {
|
||||
|
||||
let notification: InAppNotificationManager.InAppNotification
|
||||
let onTap: () -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
@State private var dragOffset: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
AvatarView(
|
||||
initials: notification.initials,
|
||||
colorIndex: notification.avatarColorIndex,
|
||||
size: 40,
|
||||
image: notification.avatar
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(notification.senderName)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Color(RosettaColors.Adaptive.text))
|
||||
.lineLimit(1)
|
||||
|
||||
Text(notification.messagePreview)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(Color(RosettaColors.Adaptive.textSecondary))
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background { TelegramGlassRoundedRect(cornerRadius: 16) }
|
||||
.padding(.horizontal, 8)
|
||||
.offset(y: min(dragOffset, 0))
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 8)
|
||||
.onChanged { value in
|
||||
// Only allow dragging upward (negative Y).
|
||||
dragOffset = min(value.translation.height, 0)
|
||||
}
|
||||
.onEnded { value in
|
||||
if value.translation.height < -30 {
|
||||
onDismiss()
|
||||
} else {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
dragOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onTap() }
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,12 @@ struct CallActionButtonsView: View {
|
||||
foreground: .white,
|
||||
pulse: true
|
||||
) {
|
||||
_ = callManager.acceptIncomingCall()
|
||||
let result = callManager.acceptIncomingCall()
|
||||
#if DEBUG
|
||||
if result != .started {
|
||||
print("[Call] Accept button failed: \(result)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 48)
|
||||
|
||||
@@ -12,8 +12,9 @@ struct BubbleContextMenuOverlay: UIViewRepresentable {
|
||||
let isOutgoing: Bool
|
||||
|
||||
/// Called when user single-taps the bubble. Receives tap location in the overlay's
|
||||
/// coordinate space (for determining which sub-element was tapped, e.g., which photo in a collage).
|
||||
var onTap: ((CGPoint) -> Void)?
|
||||
/// coordinate space (for determining which sub-element was tapped, e.g., which photo in a collage)
|
||||
/// and the overlay UIView (for converting sub-rects to global coordinates).
|
||||
var onTap: ((CGPoint, UIView?) -> Void)?
|
||||
|
||||
/// Height of the reply quote area at the top of the bubble (0 = no reply quote).
|
||||
/// Taps within this region call `onReplyQuoteTap` instead of `onTap`.
|
||||
@@ -57,7 +58,7 @@ struct BubbleContextMenuOverlay: UIViewRepresentable {
|
||||
var items: [TelegramContextMenuItem]
|
||||
var previewShape: MessageBubbleShape
|
||||
var isOutgoing: Bool
|
||||
var onTap: ((CGPoint) -> Void)?
|
||||
var onTap: ((CGPoint, UIView?) -> Void)?
|
||||
var replyQuoteHeight: CGFloat = 0
|
||||
var onReplyQuoteTap: (() -> Void)?
|
||||
private let haptic = UIImpactFeedbackGenerator(style: .medium)
|
||||
@@ -83,7 +84,7 @@ struct BubbleContextMenuOverlay: UIViewRepresentable {
|
||||
}
|
||||
}
|
||||
let location = recognizer.location(in: recognizer.view)
|
||||
onTap?(location)
|
||||
onTap?(location, recognizer.view)
|
||||
}
|
||||
|
||||
// MARK: - Long Press → Context Menu
|
||||
|
||||
@@ -210,7 +210,7 @@ struct ChatDetailView: View {
|
||||
cellActions.onForward = { [self] msg in forwardingMessage = msg; showForwardPicker = true }
|
||||
cellActions.onDelete = { [self] msg in messageToDelete = msg }
|
||||
cellActions.onCopy = { text in UIPasteboard.general.string = text }
|
||||
cellActions.onImageTap = { [self] attId in openImageViewer(attachmentId: attId) }
|
||||
cellActions.onImageTap = { [self] attId, frame in openImageViewer(attachmentId: attId, sourceFrame: frame) }
|
||||
cellActions.onScrollToMessage = { [self] msgId in
|
||||
Task { @MainActor in
|
||||
guard await viewModel.ensureMessageLoaded(messageId: msgId) else { return }
|
||||
@@ -303,11 +303,13 @@ struct ChatDetailView: View {
|
||||
OpponentProfileView(route: route)
|
||||
}
|
||||
.sheet(isPresented: $showForwardPicker) {
|
||||
ForwardChatPickerView { targetRoute in
|
||||
ForwardChatPickerView { targetRoutes in
|
||||
showForwardPicker = false
|
||||
guard let message = forwardingMessage else { return }
|
||||
forwardingMessage = nil
|
||||
forwardMessage(message, to: targetRoute)
|
||||
for route in targetRoutes {
|
||||
forwardMessage(message, to: route)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Image viewer: presented via ImageViewerPresenter (UIKit overFullScreen + crossDissolve).
|
||||
@@ -1142,7 +1144,7 @@ private extension ChatDetailView {
|
||||
/// sender name, timestamp, and caption for each image.
|
||||
/// Uses `ImageViewerPresenter` (UIKit overFullScreen) instead of SwiftUI fullScreenCover
|
||||
/// to avoid the default bottom-sheet slide-up animation.
|
||||
func openImageViewer(attachmentId: String) {
|
||||
func openImageViewer(attachmentId: String, sourceFrame: CGRect) {
|
||||
var allImages: [ViewableImageInfo] = []
|
||||
for message in messages {
|
||||
let senderName = senderDisplayName(for: message.fromPublicKey)
|
||||
@@ -1177,7 +1179,7 @@ private extension ChatDetailView {
|
||||
}
|
||||
}
|
||||
let index = allImages.firstIndex(where: { $0.attachmentId == attachmentId }) ?? 0
|
||||
let state = ImageViewerState(images: allImages, initialIndex: index)
|
||||
let state = ImageViewerState(images: allImages, initialIndex: index, sourceFrame: sourceFrame)
|
||||
ImageViewerPresenter.shared.present(state: state)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Telegram-style forward picker sheet.
|
||||
/// Shows search bar + chat list with Saved Messages always first.
|
||||
/// Two modes: single-tap (immediate forward) and multi-select (checkboxes + send button).
|
||||
struct ForwardChatPickerView: View {
|
||||
let onSelect: (ChatRoute) -> Void
|
||||
let onSelect: ([ChatRoute]) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var searchText = ""
|
||||
@State private var isMultiSelect = false
|
||||
@State private var selectedIds: Set<String> = []
|
||||
|
||||
/// Filtered + sorted dialogs: Saved Messages first, then pinned, then recent.
|
||||
/// Filtered + sorted dialogs: Saved Messages always first, then pinned, then recent.
|
||||
private var dialogs: [Dialog] {
|
||||
let all = DialogRepository.shared.sortedDialogs.filter {
|
||||
($0.iHaveSent || $0.isSavedMessages) && !SystemAccounts.isSystemAccount($0.opponentKey)
|
||||
@@ -25,7 +27,6 @@ struct ForwardChatPickerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Saved Messages always first
|
||||
var saved: Dialog?
|
||||
var rest: [Dialog] = []
|
||||
for dialog in filtered {
|
||||
@@ -35,67 +36,183 @@ struct ForwardChatPickerView: View {
|
||||
rest.append(dialog)
|
||||
}
|
||||
}
|
||||
if let saved {
|
||||
return [saved] + rest
|
||||
}
|
||||
if let saved { return [saved] + rest }
|
||||
return rest
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
ForwardPickerSearchBar(searchText: $searchText)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
if dialogs.isEmpty && !searchText.isEmpty {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text("No chats found")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
Spacer()
|
||||
VStack(spacing: 0) {
|
||||
// MARK: - Header
|
||||
ForwardPickerHeader(
|
||||
isMultiSelect: isMultiSelect,
|
||||
onClose: { dismiss() },
|
||||
onSelect: {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isMultiSelect = true
|
||||
}
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(dialogs.enumerated()), id: \.element.id) { index, dialog in
|
||||
ForwardPickerRow(dialog: dialog) {
|
||||
onSelect(ChatRoute(dialog: dialog))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if index < dialogs.count - 1 {
|
||||
Divider()
|
||||
.padding(.leading, 70)
|
||||
.foregroundStyle(RosettaColors.Adaptive.divider)
|
||||
// MARK: - Search
|
||||
ForwardPickerSearchBar(searchText: $searchText)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 6)
|
||||
|
||||
// MARK: - Chat List
|
||||
if dialogs.isEmpty && !searchText.isEmpty {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text("No chats found")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(Color(white: 0.5))
|
||||
Spacer()
|
||||
}
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(dialogs.enumerated()), id: \.element.id) { index, dialog in
|
||||
ForwardPickerRow(
|
||||
dialog: dialog,
|
||||
isMultiSelect: isMultiSelect,
|
||||
isSelected: selectedIds.contains(dialog.opponentKey)
|
||||
) {
|
||||
if isMultiSelect {
|
||||
withAnimation(.easeInOut(duration: 0.15)) {
|
||||
if selectedIds.contains(dialog.opponentKey) {
|
||||
selectedIds.remove(dialog.opponentKey)
|
||||
} else {
|
||||
selectedIds.insert(dialog.opponentKey)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
onSelect([ChatRoute(dialog: dialog)])
|
||||
}
|
||||
}
|
||||
|
||||
if index < dialogs.count - 1 {
|
||||
Divider()
|
||||
.padding(.leading, 65)
|
||||
.foregroundStyle(Color(red: 0x54 / 255.0, green: 0x54 / 255.0, blue: 0x58 / 255.0).opacity(0.55))
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
}
|
||||
.background(RosettaColors.Dark.background.ignoresSafeArea())
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
|
||||
// MARK: - Bottom Bar (multi-select)
|
||||
if isMultiSelect {
|
||||
ForwardPickerBottomBar(
|
||||
selectedCount: selectedIds.count,
|
||||
onSend: {
|
||||
let routes = dialogs
|
||||
.filter { selectedIds.contains($0.opponentKey) }
|
||||
.map { ChatRoute(dialog: $0) }
|
||||
if !routes.isEmpty { onSelect(routes) }
|
||||
}
|
||||
)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.background(Color.black.ignoresSafeArea())
|
||||
.preferredColorScheme(.dark)
|
||||
.presentationBackground(Color.black)
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Telegram Icons (CoreGraphics-exact replicas)
|
||||
|
||||
/// Telegram X close icon: two diagonal lines, 2pt stroke, round cap.
|
||||
/// Source: PresentationResourcesChat.swift:215
|
||||
private struct TelegramCloseIcon: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var p = Path()
|
||||
let inset: CGFloat = 1.0
|
||||
p.move(to: CGPoint(x: inset, y: inset))
|
||||
p.addLine(to: CGPoint(x: rect.width - inset, y: rect.height - inset))
|
||||
p.move(to: CGPoint(x: rect.width - inset, y: inset))
|
||||
p.addLine(to: CGPoint(x: inset, y: rect.height - inset))
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
/// Telegram send arrow: arrowhead + vertical stem.
|
||||
/// Source: PresentationResourcesChat.swift:365 (SVG paths)
|
||||
private struct TelegramSendArrow: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let sx = rect.width / 33.0
|
||||
let sy = rect.height / 33.0
|
||||
var p = Path()
|
||||
// Arrowhead (V pointing up)
|
||||
p.move(to: CGPoint(x: 11 * sx, y: 14.667 * sy))
|
||||
p.addLine(to: CGPoint(x: 16.5 * sx, y: 9.4 * sy))
|
||||
p.addLine(to: CGPoint(x: 22 * sx, y: 14.667 * sy))
|
||||
// Stem (vertical rounded rect)
|
||||
p.addRoundedRect(
|
||||
in: CGRect(x: 15.5 * sx, y: 9.333 * sy, width: 2 * sx, height: 15.667 * sy),
|
||||
cornerSize: CGSize(width: 1 * sx, height: 1 * sy)
|
||||
)
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
/// Telegram checkmark: L-shaped path, 1.5pt stroke, round cap/join.
|
||||
/// Source: CheckNode.swift:468-600
|
||||
private struct TelegramCheckmark: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let scale = min(rect.width, rect.height) / 18.0
|
||||
let cx = rect.midX
|
||||
let cy = rect.midY
|
||||
let start = CGPoint(x: cx - 3.5 * scale, y: cy + 0.5 * scale)
|
||||
var p = Path()
|
||||
p.move(to: start)
|
||||
p.addLine(to: CGPoint(x: start.x + 2.5 * scale, y: start.y + 3.0 * scale))
|
||||
p.addLine(to: CGPoint(x: start.x + 2.5 * scale + 4.667 * scale, y: start.y + 3.0 * scale - 6.0 * scale))
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private struct ForwardPickerHeader: View {
|
||||
let isMultiSelect: Bool
|
||||
let onClose: () -> Void
|
||||
let onSelect: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
HStack {
|
||||
// Telegram close button: 30pt dark circle + 12x12 X icon
|
||||
Button(action: onClose) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color(white: 0.16))
|
||||
.frame(width: 30, height: 30)
|
||||
TelegramCloseIcon()
|
||||
.stroke(.white, style: StrokeStyle(lineWidth: 2, lineCap: .round))
|
||||
.frame(width: 12, height: 12)
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .principal) {
|
||||
Text("Forward")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Text("Forward")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
if !isMultiSelect {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Select", action: onSelect)
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
.toolbarBackground(RosettaColors.Dark.background, for: .navigationBar)
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 52)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,32 +223,39 @@ private struct ForwardPickerSearchBar: View {
|
||||
@FocusState private var isFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(Color.gray)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(Color(white: 0.56))
|
||||
|
||||
TextField("Search", text: $searchText)
|
||||
.font(.system(size: 17))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.focused($isFocused)
|
||||
.submitLabel(.search)
|
||||
ZStack(alignment: .leading) {
|
||||
if searchText.isEmpty {
|
||||
Text("Search")
|
||||
.font(.system(size: 17))
|
||||
.foregroundStyle(Color(white: 0.56))
|
||||
}
|
||||
TextField("", text: $searchText)
|
||||
.font(.system(size: 17))
|
||||
.foregroundStyle(.white)
|
||||
.focused($isFocused)
|
||||
.submitLabel(.search)
|
||||
}
|
||||
|
||||
if !searchText.isEmpty {
|
||||
Button {
|
||||
searchText = ""
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(Color.gray)
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(Color(white: 0.56))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: 42)
|
||||
.padding(.horizontal, 14)
|
||||
.frame(height: 44)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 21, style: .continuous)
|
||||
.fill(RosettaColors.Adaptive.backgroundSecondary)
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.fill(Color(white: 0.14))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,28 +264,34 @@ private struct ForwardPickerSearchBar: View {
|
||||
|
||||
private struct ForwardPickerRow: View {
|
||||
let dialog: Dialog
|
||||
let isMultiSelect: Bool
|
||||
let isSelected: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
ForwardPickerRowAvatar(dialog: dialog)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Text(dialog.isSavedMessages ? "Saved Messages" : dialog.opponentTitle)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
|
||||
if dialog.effectiveVerified > 0 && !dialog.isSavedMessages {
|
||||
VerifiedBadge(verified: dialog.effectiveVerified, size: 14)
|
||||
VerifiedBadge(verified: dialog.effectiveVerified, size: 16)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isMultiSelect {
|
||||
SelectionCircle(isSelected: isSelected)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 56)
|
||||
.padding(.leading, 15).padding(.trailing, 16)
|
||||
.frame(height: 48)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -178,9 +308,80 @@ private struct ForwardPickerRowAvatar: View {
|
||||
AvatarView(
|
||||
initials: dialog.initials,
|
||||
colorIndex: dialog.avatarColorIndex,
|
||||
size: 42,
|
||||
size: 40,
|
||||
isSavedMessages: dialog.isSavedMessages,
|
||||
image: dialog.isSavedMessages ? nil : AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection Circle (Telegram CheckNode replica)
|
||||
|
||||
private struct SelectionCircle: View {
|
||||
let isSelected: Bool
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if isSelected {
|
||||
Circle()
|
||||
.fill(RosettaColors.primaryBlue)
|
||||
.frame(width: 22, height: 22)
|
||||
TelegramCheckmark()
|
||||
.stroke(.white, style: StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round))
|
||||
.frame(width: 22, height: 22)
|
||||
} else {
|
||||
Circle()
|
||||
.stroke(Color(white: 0.35), lineWidth: 1.5)
|
||||
.frame(width: 22, height: 22)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bottom Bar
|
||||
|
||||
private struct ForwardPickerBottomBar: View {
|
||||
let selectedCount: Int
|
||||
let onSend: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(Color(white: 0.2))
|
||||
.frame(height: 0.5)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
Text("Message")
|
||||
.font(.system(size: 17))
|
||||
.foregroundStyle(Color(white: 0.35))
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 42)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 21, style: .continuous)
|
||||
.fill(Color(white: 0.14))
|
||||
}
|
||||
|
||||
// Telegram send button: 33pt circle + SVG arrow
|
||||
Button(action: onSend) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(selectedCount > 0
|
||||
? RosettaColors.primaryBlue
|
||||
: Color(white: 0.2))
|
||||
.frame(width: 33, height: 33)
|
||||
TelegramSendArrow()
|
||||
.fill(.white)
|
||||
.frame(width: 33, height: 33)
|
||||
}
|
||||
}
|
||||
.disabled(selectedCount == 0)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.background(Color.black)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ struct ViewableImageInfo: Equatable, Identifiable {
|
||||
struct ImageViewerState: Equatable {
|
||||
let images: [ViewableImageInfo]
|
||||
let initialIndex: Int
|
||||
let sourceFrame: CGRect
|
||||
}
|
||||
|
||||
// MARK: - ImageViewerPresenter
|
||||
@@ -71,7 +72,8 @@ final class ImageViewerPresenter {
|
||||
|
||||
// MARK: - ImageGalleryViewer
|
||||
|
||||
/// Telegram-style multi-photo gallery viewer with horizontal paging.
|
||||
/// Telegram-style multi-photo gallery viewer with hero transition animation.
|
||||
/// Reference: PhotosTransition/Helpers/PhotoGridView.swift — hero expand/collapse pattern.
|
||||
/// Android parity: `ImageViewerScreen.kt` — top bar with sender/date,
|
||||
/// bottom caption bar, edge-tap navigation, velocity dismiss, share/save.
|
||||
struct ImageGalleryViewer: View {
|
||||
@@ -82,10 +84,13 @@ struct ImageGalleryViewer: View {
|
||||
@State private var currentPage: Int
|
||||
@State private var showControls = true
|
||||
@State private var currentZoomScale: CGFloat = 1.0
|
||||
@State private var backgroundOpacity: Double = 1.0
|
||||
@State private var isDismissing = false
|
||||
/// Entry/exit animation progress (0 = hidden, 1 = fully visible).
|
||||
@State private var presentationAlpha: Double = 0
|
||||
/// Hero transition state: false = positioned at source frame, true = fullscreen.
|
||||
@State private var isExpanded: Bool = false
|
||||
/// Drag offset for interactive pan-to-dismiss.
|
||||
@State private var dragOffset: CGSize = .zero
|
||||
/// Full screen dimensions (captured from geometry).
|
||||
@State private var viewSize: CGSize = UIScreen.main.bounds.size
|
||||
|
||||
private static let dateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
@@ -103,27 +108,37 @@ struct ImageGalleryViewer: View {
|
||||
state.images.indices.contains(currentPage) ? state.images[currentPage] : nil
|
||||
}
|
||||
|
||||
/// Whether the source frame is valid for hero animation (non-zero).
|
||||
private var hasHeroSource: Bool {
|
||||
state.sourceFrame.width > 0 && state.sourceFrame.height > 0
|
||||
}
|
||||
|
||||
/// Hero animation spring — matches PhotosTransition reference.
|
||||
private var heroAnimation: Animation {
|
||||
.interpolatingSpring(duration: 0.3, bounce: 0, initialVelocity: 0)
|
||||
}
|
||||
|
||||
/// Opacity that decreases as user drags further from center.
|
||||
private var interactiveOpacity: CGFloat {
|
||||
let opacityY = abs(dragOffset.height) / (viewSize.height * 0.3)
|
||||
return max(1 - opacityY, 0)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let sourceFrame = state.sourceFrame
|
||||
|
||||
ZStack {
|
||||
// Background — fades during drag-to-dismiss and entry/exit
|
||||
// Background — fades with hero expansion and drag progress
|
||||
Color.black
|
||||
.opacity(backgroundOpacity * presentationAlpha)
|
||||
.opacity(isExpanded ? interactiveOpacity : 0)
|
||||
.ignoresSafeArea()
|
||||
|
||||
// Pager
|
||||
// Pager with hero positioning
|
||||
TabView(selection: $currentPage) {
|
||||
ForEach(Array(state.images.enumerated()), id: \.element.attachmentId) { index, info in
|
||||
ZoomableImagePage(
|
||||
attachmentId: info.attachmentId,
|
||||
onDismiss: { smoothDismiss() },
|
||||
onDismissProgress: { progress in
|
||||
backgroundOpacity = 1.0 - Double(progress) * 0.7
|
||||
},
|
||||
onDismissCancel: {
|
||||
withAnimation(.easeOut(duration: 0.25)) {
|
||||
backgroundOpacity = 1.0
|
||||
}
|
||||
},
|
||||
onDismiss: { dismiss() },
|
||||
showControls: $showControls,
|
||||
currentScale: $currentZoomScale,
|
||||
onEdgeTap: { direction in
|
||||
@@ -134,23 +149,59 @@ struct ImageGalleryViewer: View {
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
// Block TabView page swipe when zoomed or dismissing,
|
||||
// but ONLY the scroll — NOT all user interaction.
|
||||
// .disabled() kills ALL gestures (taps, pinch, etc.) which prevents
|
||||
// double-tap zoom out. .scrollDisabled() only blocks the page swipe.
|
||||
.scrollDisabled(currentZoomScale > 1.05 || isDismissing)
|
||||
.opacity(presentationAlpha)
|
||||
// Hero frame: source rect when collapsed, full screen when expanded
|
||||
.frame(
|
||||
width: isExpanded ? viewSize.width : (hasHeroSource ? sourceFrame.width : viewSize.width),
|
||||
height: isExpanded ? viewSize.height : (hasHeroSource ? sourceFrame.height : viewSize.height)
|
||||
)
|
||||
.clipped()
|
||||
.offset(
|
||||
x: isExpanded ? 0 : (hasHeroSource ? sourceFrame.minX : 0),
|
||||
y: isExpanded ? 0 : (hasHeroSource ? sourceFrame.minY : 0)
|
||||
)
|
||||
.offset(dragOffset)
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
maxHeight: .infinity,
|
||||
alignment: isExpanded ? .center : (hasHeroSource ? .topLeading : .center)
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
// Interactive drag gesture for hero dismiss (vertical only, when not zoomed)
|
||||
.simultaneousGesture(
|
||||
currentZoomScale <= 1.05 ?
|
||||
DragGesture(minimumDistance: 40)
|
||||
.onChanged { value in
|
||||
let dy = abs(value.translation.height)
|
||||
let dx = abs(value.translation.width)
|
||||
guard dy > dx * 2.0 else { return }
|
||||
dragOffset = .init(width: value.translation.width, height: value.translation.height)
|
||||
}
|
||||
.onEnded { value in
|
||||
if dragOffset.height > 50 {
|
||||
heroDismiss()
|
||||
} else {
|
||||
withAnimation(heroAnimation.speed(1.2)) {
|
||||
dragOffset = .zero
|
||||
}
|
||||
}
|
||||
}
|
||||
: nil
|
||||
)
|
||||
|
||||
// Controls overlay
|
||||
// Controls overlay — fades with hero expansion
|
||||
controlsOverlay
|
||||
.opacity(presentationAlpha)
|
||||
.opacity(isExpanded ? 1 : 0)
|
||||
.opacity(interactiveOpacity)
|
||||
}
|
||||
.statusBarHidden(true)
|
||||
.onAppear {
|
||||
.allowsHitTesting(isExpanded)
|
||||
.onGeometryChange(for: CGSize.self, of: { $0.size }) { viewSize = $0 }
|
||||
.task {
|
||||
prefetchAdjacentImages(around: state.initialIndex)
|
||||
// Android: 200ms entry animation (TelegramEasing)
|
||||
withAnimation(.easeOut(duration: 0.2)) {
|
||||
presentationAlpha = 1.0
|
||||
guard !isExpanded else { return }
|
||||
withAnimation(heroAnimation) {
|
||||
isExpanded = true
|
||||
}
|
||||
}
|
||||
.onChange(of: currentPage) { _, newPage in
|
||||
@@ -163,7 +214,6 @@ struct ImageGalleryViewer: View {
|
||||
@ViewBuilder
|
||||
private var controlsOverlay: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Android parity: slide + fade, 200ms, FastOutSlowInEasing, 24pt slide distance.
|
||||
if showControls && !isDismissing {
|
||||
topBar
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
@@ -177,19 +227,17 @@ struct ImageGalleryViewer: View {
|
||||
.animation(.easeOut(duration: 0.2), value: showControls)
|
||||
}
|
||||
|
||||
// MARK: - Top Bar (Android: sender name + date, back arrow)
|
||||
// MARK: - Top Bar
|
||||
|
||||
private var topBar: some View {
|
||||
HStack(spacing: 8) {
|
||||
// Back button (Android: arrow back on left)
|
||||
Button { smoothDismiss() } label: {
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
|
||||
// Sender name + date
|
||||
if let info = currentInfo {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(info.senderName)
|
||||
@@ -205,7 +253,6 @@ struct ImageGalleryViewer: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
// Page counter (if multiple images)
|
||||
if state.images.count > 1 {
|
||||
Text("\(currentPage + 1) / \(state.images.count)")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
@@ -215,15 +262,13 @@ struct ImageGalleryViewer: View {
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 8)
|
||||
// Extend dark background up into the notch / Dynamic Island safe area
|
||||
.background(Color.black.opacity(0.5).ignoresSafeArea(edges: .top))
|
||||
}
|
||||
|
||||
// MARK: - Bottom Bar (Caption + Share/Save)
|
||||
// MARK: - Bottom Bar
|
||||
|
||||
private var bottomBar: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Caption text (Android: AppleEmojiText, 15sp, 4 lines max)
|
||||
if let caption = currentInfo?.caption, !caption.isEmpty {
|
||||
Text(caption)
|
||||
.font(.system(size: 15))
|
||||
@@ -235,7 +280,6 @@ struct ImageGalleryViewer: View {
|
||||
.background(Color.black.opacity(0.5))
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
HStack(spacing: 32) {
|
||||
Button { shareCurrentImage() } label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
@@ -255,7 +299,6 @@ struct ImageGalleryViewer: View {
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 8)
|
||||
// Extend dark background down into the home indicator safe area
|
||||
.background(Color.black.opacity(0.5).ignoresSafeArea(edges: .bottom))
|
||||
}
|
||||
}
|
||||
@@ -265,18 +308,42 @@ struct ImageGalleryViewer: View {
|
||||
private func navigateEdgeTap(direction: Int) {
|
||||
let target = currentPage + direction
|
||||
guard target >= 0, target < state.images.count else { return }
|
||||
// Android: instant page switch with short fade (120ms)
|
||||
currentPage = target
|
||||
}
|
||||
|
||||
// MARK: - Smooth Dismiss (Android: 200ms fade-out)
|
||||
// MARK: - Dismiss
|
||||
|
||||
private func smoothDismiss() {
|
||||
/// Unified dismiss: hero collapse when not zoomed, fade when zoomed.
|
||||
private func dismiss() {
|
||||
if currentZoomScale > 1.05 {
|
||||
fadeDismiss()
|
||||
} else {
|
||||
heroDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
/// Hero collapse back to source frame.
|
||||
private func heroDismiss() {
|
||||
guard !isDismissing else { return }
|
||||
isDismissing = true
|
||||
|
||||
Task {
|
||||
withAnimation(heroAnimation.speed(1.2)) {
|
||||
dragOffset = .zero
|
||||
isExpanded = false
|
||||
}
|
||||
try? await Task.sleep(for: .seconds(0.35))
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback fade dismiss when zoomed.
|
||||
private func fadeDismiss() {
|
||||
guard !isDismissing else { return }
|
||||
isDismissing = true
|
||||
|
||||
withAnimation(.easeOut(duration: 0.2)) {
|
||||
presentationAlpha = 0
|
||||
isExpanded = false
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) {
|
||||
@@ -321,7 +388,6 @@ struct ImageGalleryViewer: View {
|
||||
// MARK: - Prefetch
|
||||
|
||||
private func prefetchAdjacentImages(around index: Int) {
|
||||
// Android: prefetches ±2 images from current page
|
||||
for offset in [-2, -1, 1, 2] {
|
||||
let i = index + offset
|
||||
guard i >= 0, i < state.images.count else { continue }
|
||||
@@ -334,5 +400,5 @@ struct ImageGalleryViewer: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ final class MessageCellActions {
|
||||
var onForward: (ChatMessage) -> Void = { _ in }
|
||||
var onDelete: (ChatMessage) -> Void = { _ in }
|
||||
var onCopy: (String) -> Void = { _ in }
|
||||
var onImageTap: (String) -> Void = { _ in }
|
||||
var onImageTap: (String, CGRect) -> Void = { _, _ in }
|
||||
var onScrollToMessage: (String) -> Void = { _ in }
|
||||
var onRetry: (ChatMessage) -> Void = { _ in }
|
||||
var onRemove: (ChatMessage) -> Void = { _ in }
|
||||
|
||||
@@ -197,7 +197,7 @@ struct MessageCellView: View, Equatable {
|
||||
attachments: imageAttachments,
|
||||
outgoing: outgoing,
|
||||
maxWidth: imageContentWidth,
|
||||
onImageTap: { attId in actions.onImageTap(attId) }
|
||||
onImageTap: { attId in actions.onImageTap(attId, .zero) }
|
||||
)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.top, 4)
|
||||
@@ -250,9 +250,10 @@ struct MessageCellView: View, Equatable {
|
||||
items: contextMenuItems(for: message),
|
||||
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
|
||||
isOutgoing: outgoing,
|
||||
onTap: !imageAttachments.isEmpty ? { _ in
|
||||
onTap: !imageAttachments.isEmpty ? { _, overlayView in
|
||||
if let firstId = imageAttachments.first?.id {
|
||||
actions.onImageTap(firstId)
|
||||
let frame = overlayView?.convert(overlayView?.bounds ?? .zero, to: nil) ?? .zero
|
||||
actions.onImageTap(firstId, frame)
|
||||
}
|
||||
} : nil
|
||||
)
|
||||
@@ -350,7 +351,7 @@ struct MessageCellView: View, Equatable {
|
||||
items: contextMenuItems(for: message),
|
||||
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
|
||||
isOutgoing: outgoing,
|
||||
onTap: !attachments.isEmpty ? { tapLocation in
|
||||
onTap: !attachments.isEmpty ? { tapLocation, overlayView in
|
||||
if !imageAttachments.isEmpty {
|
||||
let tappedId = imageAttachments.count == 1
|
||||
? imageAttachments[0].id
|
||||
@@ -360,7 +361,8 @@ struct MessageCellView: View, Equatable {
|
||||
maxWidth: maxBubbleWidth
|
||||
)
|
||||
if AttachmentCache.shared.loadImage(forAttachmentId: tappedId) != nil {
|
||||
actions.onImageTap(tappedId)
|
||||
let frame = overlayView?.convert(overlayView?.bounds ?? .zero, to: nil) ?? .zero
|
||||
actions.onImageTap(tappedId, frame)
|
||||
} else {
|
||||
NotificationCenter.default.post(
|
||||
name: .triggerAttachmentDownload, object: tappedId
|
||||
|
||||
@@ -1404,8 +1404,11 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
}
|
||||
|
||||
let attachment = photoAttachments[sender.tag]
|
||||
let imageView = photoTileImageViews[sender.tag]
|
||||
let sourceFrame = imageView.convert(imageView.bounds, to: nil)
|
||||
|
||||
if AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) != nil {
|
||||
actions.onImageTap(attachment.id)
|
||||
actions.onImageTap(attachment.id, sourceFrame)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1422,7 +1425,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
return
|
||||
}
|
||||
if loaded != nil {
|
||||
actions.onImageTap(attachment.id)
|
||||
actions.onImageTap(attachment.id, sourceFrame)
|
||||
} else {
|
||||
self.downloadPhotoAttachment(attachment: attachment, message: message)
|
||||
}
|
||||
|
||||
@@ -10,15 +10,11 @@ struct ZoomableImagePage: View {
|
||||
|
||||
let attachmentId: String
|
||||
let onDismiss: () -> Void
|
||||
let onDismissProgress: (CGFloat) -> Void
|
||||
let onDismissCancel: () -> Void
|
||||
@Binding var showControls: Bool
|
||||
@Binding var currentScale: CGFloat
|
||||
let onEdgeTap: ((Int) -> Void)?
|
||||
|
||||
@State private var image: UIImage?
|
||||
/// Vertical drag offset for dismiss gesture (SwiftUI DragGesture).
|
||||
@State private var dismissDragOffset: CGFloat = 0
|
||||
|
||||
@State private var zoomScale: CGFloat = 1.0
|
||||
@State private var zoomOffset: CGSize = .zero
|
||||
@@ -34,7 +30,7 @@ struct ZoomableImagePage: View {
|
||||
.scaledToFit()
|
||||
.scaleEffect(effectiveScale)
|
||||
.offset(x: effectiveScale > 1.05 ? zoomOffset.width : 0,
|
||||
y: (effectiveScale > 1.05 ? zoomOffset.height : 0) + dismissDragOffset)
|
||||
y: effectiveScale > 1.05 ? zoomOffset.height : 0)
|
||||
// Expand hit-test area to full screen — scaleEffect is visual-only
|
||||
// and doesn't grow the Image's gesture frame. Without this,
|
||||
// double-tap to zoom out doesn't work on zoomed-in edges.
|
||||
@@ -94,13 +90,7 @@ struct ZoomableImagePage: View {
|
||||
}
|
||||
: nil
|
||||
)
|
||||
// Dismiss drag (vertical swipe when not zoomed)
|
||||
// simultaneousGesture so it coexists with TabView's page swipe.
|
||||
// The 2.0× vertical ratio in dismissDragGesture prevents
|
||||
// horizontal swipes from triggering dismiss.
|
||||
.simultaneousGesture(
|
||||
zoomScale <= 1.05 ? dismissDragGesture : nil
|
||||
)
|
||||
// Dismiss drag handled by HeroPanGesture on ImageGalleryViewer level.
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
@@ -121,35 +111,6 @@ struct ZoomableImagePage: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertical drag-to-dismiss gesture.
|
||||
/// Uses minimumDistance:40 to give TabView's page swipe a head start.
|
||||
private var dismissDragGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 40, coordinateSpace: .local)
|
||||
.onChanged { value in
|
||||
let dy = abs(value.translation.height)
|
||||
let dx = abs(value.translation.width)
|
||||
// Only vertical-dominant drags trigger dismiss
|
||||
guard dy > dx * 2.0 else { return }
|
||||
|
||||
dismissDragOffset = value.translation.height
|
||||
let progress = min(abs(dismissDragOffset) / 300, 1.0)
|
||||
onDismissProgress(progress)
|
||||
}
|
||||
.onEnded { value in
|
||||
let velocityY = abs(value.predictedEndTranslation.height - value.translation.height)
|
||||
if abs(dismissDragOffset) > 100 || velocityY > 500 {
|
||||
// Dismiss — keep offset so photo doesn't jump back before fade-out
|
||||
onDismiss()
|
||||
} else {
|
||||
// Snap back
|
||||
withAnimation(.easeOut(duration: 0.25)) {
|
||||
dismissDragOffset = 0
|
||||
}
|
||||
onDismissCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Placeholder
|
||||
|
||||
private var placeholder: some View {
|
||||
|
||||
@@ -12,6 +12,7 @@ struct MainTabView: View {
|
||||
@State private var isSettingsEditPresented = false
|
||||
@State private var isSettingsDetailPresented = false
|
||||
@StateObject private var callManager = CallManager.shared
|
||||
@StateObject private var notificationManager = InAppNotificationManager.shared
|
||||
|
||||
// Add Account — presented as fullScreenCover so Settings stays alive.
|
||||
// Using optional AuthScreen as the item ensures the correct screen is
|
||||
@@ -59,7 +60,10 @@ struct MainTabView: View {
|
||||
// Applied HERE (not on outer ZStack) so the full-screen overlay
|
||||
// is NOT affected — prevents jerk when overlay transitions out.
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
if callManager.uiState.isVisible && callManager.uiState.isMinimized {
|
||||
// No minimized bar for .incoming — CallKit handles incoming UI.
|
||||
// Bar appears only after accept (keyExchange, webRtcExchange, active).
|
||||
if callManager.uiState.isVisible && callManager.uiState.isMinimized
|
||||
&& callManager.uiState.phase != .incoming {
|
||||
MinimizedCallBar(callManager: callManager)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
} else {
|
||||
@@ -67,6 +71,39 @@ struct MainTabView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// In-app notification banner overlay (Telegram parity).
|
||||
// Slides from top, auto-dismisses after 5s, tap navigates to chat.
|
||||
Group {
|
||||
if let notification = notificationManager.currentNotification {
|
||||
VStack {
|
||||
InAppNotificationBanner(
|
||||
notification: notification,
|
||||
onTap: {
|
||||
let route = ChatRoute(
|
||||
publicKey: notification.senderKey,
|
||||
title: notification.senderName,
|
||||
username: "",
|
||||
verified: 0
|
||||
)
|
||||
notificationManager.dismiss()
|
||||
if selectedTab != .chats {
|
||||
selectedTab = .chats
|
||||
}
|
||||
NotificationCenter.default.post(
|
||||
name: .openChatFromNotification,
|
||||
object: route
|
||||
)
|
||||
},
|
||||
onDismiss: { notificationManager.dismiss() }
|
||||
)
|
||||
.padding(.top, 4)
|
||||
Spacer()
|
||||
}
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.zIndex(9)
|
||||
|
||||
// Full-screen device verification overlay (observation-isolated).
|
||||
// Covers nav bar, search bar, and tab bar — desktop parity.
|
||||
DeviceConfirmOverlay()
|
||||
@@ -79,8 +116,11 @@ struct MainTabView: View {
|
||||
// Full-screen call overlay — OUTSIDE .safeAreaInset scope.
|
||||
// Animation driven by withAnimation in CallManager methods —
|
||||
// no .animation() modifiers here to avoid NavigationStack conflicts.
|
||||
// Custom call overlay — only AFTER accept (not for .incoming).
|
||||
// Telegram parity: CallKit handles incoming UI, custom UI handles active call.
|
||||
Group {
|
||||
if callManager.uiState.isFullScreenVisible {
|
||||
if callManager.uiState.isFullScreenVisible
|
||||
&& callManager.uiState.phase != .incoming {
|
||||
ActiveCallOverlayView(callManager: callManager)
|
||||
// Asymmetric: slide-in from bottom on appear,
|
||||
// fade-only on removal to avoid conflict with dragOffset
|
||||
|
||||
@@ -339,7 +339,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
||||
/// Server may use different key names across versions.
|
||||
/// Note: server currently sends `from` field — checked first in didReceiveRemoteNotification,
|
||||
/// this helper is a fallback for other contexts (notification tap, etc.).
|
||||
private static func extractSenderKey(from userInfo: [AnyHashable: Any]) -> String {
|
||||
static func extractSenderKey(from userInfo: [AnyHashable: Any]) -> String {
|
||||
firstNonBlank(userInfo, keys: [
|
||||
"dialog", "sender_public_key", "from_public_key", "fromPublicKey",
|
||||
"public_key", "publicKey"
|
||||
@@ -368,15 +368,41 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
|
||||
|
||||
// MARK: - UNUserNotificationCenterDelegate
|
||||
|
||||
/// Handle foreground notifications — suppress ALL when app is in foreground.
|
||||
/// Android parity: messages arrive via WebSocket in real-time, push is background-only.
|
||||
/// Handle foreground notifications — always suppress system banner,
|
||||
/// show custom in-app overlay instead (Telegram parity).
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) ->
|
||||
Void
|
||||
) {
|
||||
let userInfo = notification.request.content.userInfo
|
||||
|
||||
// Always suppress system banner — custom in-app overlay handles display.
|
||||
completionHandler([])
|
||||
|
||||
// Trigger in-app notification banner (suppression logic inside manager).
|
||||
Task { @MainActor in
|
||||
InAppNotificationManager.shared.show(userInfo: userInfo)
|
||||
}
|
||||
}
|
||||
|
||||
/// Determines whether a foreground notification should be suppressed.
|
||||
/// Testable: used by unit tests to verify suppression logic.
|
||||
static func foregroundPresentationOptions(
|
||||
for userInfo: [AnyHashable: Any]
|
||||
) -> UNNotificationPresentationOptions {
|
||||
let senderKey = userInfo["dialog"] as? String
|
||||
?? extractSenderKey(from: userInfo)
|
||||
|
||||
// Always suppress system banner — custom in-app overlay handles display.
|
||||
// InAppNotificationManager.shouldSuppress() has the full suppression logic.
|
||||
if InAppNotificationManager.shouldSuppress(senderKey: senderKey) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Even for non-suppressed notifications, return [] — we show our own banner.
|
||||
return []
|
||||
}
|
||||
|
||||
/// Handle notification tap — navigate to the sender's chat.
|
||||
@@ -477,9 +503,9 @@ extension AppDelegate: PKPushRegistryDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger WebSocket reconnection so the actual .call signal packet
|
||||
// arrives and CallManager can handle the call. Without this, the app
|
||||
// wakes from killed state but CallManager stays idle → Accept does nothing.
|
||||
// Trigger WebSocket reconnection so the actual .call signal
|
||||
// packet arrives and CallManager can handle the call. Without this, the
|
||||
// app wakes from killed state but CallManager stays idle → Accept does nothing.
|
||||
Task { @MainActor in
|
||||
if ProtocolManager.shared.connectionState != .authenticated {
|
||||
ProtocolManager.shared.forceReconnectOnForeground()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
import Intents
|
||||
|
||||
@@ -161,7 +162,13 @@ final class NotificationService: UNNotificationServiceExtension {
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Normalize sender_public_key in userInfo for tap navigation.
|
||||
// 5. Group notifications by conversation (Telegram parity).
|
||||
// iOS stacks notifications from the same chat together.
|
||||
if !senderKey.isEmpty {
|
||||
content.threadIdentifier = senderKey
|
||||
}
|
||||
|
||||
// 6. Normalize sender_public_key in userInfo for tap navigation.
|
||||
var updatedInfo = content.userInfo
|
||||
if !senderKey.isEmpty {
|
||||
updatedInfo["sender_public_key"] = senderKey
|
||||
@@ -217,11 +224,13 @@ final class NotificationService: UNNotificationServiceExtension {
|
||||
senderKey: String
|
||||
) -> UNNotificationContent {
|
||||
let handle = INPersonHandle(value: senderKey, type: .unknown)
|
||||
let displayName = senderName.isEmpty ? "Rosetta" : senderName
|
||||
let avatarImage = generateLetterAvatar(name: displayName, key: senderKey)
|
||||
let sender = INPerson(
|
||||
personHandle: handle,
|
||||
nameComponents: nil,
|
||||
displayName: senderName.isEmpty ? "Rosetta" : senderName,
|
||||
image: nil,
|
||||
displayName: displayName,
|
||||
image: avatarImage,
|
||||
contactIdentifier: nil,
|
||||
customIdentifier: senderKey
|
||||
)
|
||||
@@ -237,6 +246,11 @@ final class NotificationService: UNNotificationServiceExtension {
|
||||
attachments: nil
|
||||
)
|
||||
|
||||
// Set avatar on sender parameter (Telegram parity: 50x50 letter avatar).
|
||||
if let avatarImage {
|
||||
intent.setImage(avatarImage, forParameterNamed: \.sender)
|
||||
}
|
||||
|
||||
// Donate the intent so Siri can learn communication patterns.
|
||||
let interaction = INInteraction(intent: intent, response: nil)
|
||||
interaction.direction = .incoming
|
||||
@@ -254,6 +268,68 @@ final class NotificationService: UNNotificationServiceExtension {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Letter Avatar (Telegram parity: colored circle with initials)
|
||||
|
||||
/// Mantine avatar color palette — matches AvatarView in main app.
|
||||
private static let avatarColors: [(bg: UInt32, text: UInt32)] = [
|
||||
(0x4C6EF5, 0xDBE4FF), // indigo
|
||||
(0x7950F2, 0xE5DBFF), // violet
|
||||
(0xF06595, 0xFFDEEB), // pink
|
||||
(0xFF6B6B, 0xFFE3E3), // red
|
||||
(0xFD7E14, 0xFFE8CC), // orange
|
||||
(0xFAB005, 0xFFF3BF), // yellow
|
||||
(0x40C057, 0xD3F9D8), // green
|
||||
(0x12B886, 0xC3FAE8), // teal
|
||||
(0x15AABF, 0xC5F6FA), // cyan
|
||||
(0x228BE6, 0xD0EBFF), // blue
|
||||
(0xBE4BDB, 0xF3D9FA), // grape
|
||||
]
|
||||
|
||||
/// Generates a 50x50 circular letter avatar as INImage for notification display.
|
||||
private static func generateLetterAvatar(name: String, key: String) -> INImage? {
|
||||
let size: CGFloat = 50
|
||||
let colorIndex = abs(key.hashValue) % avatarColors.count
|
||||
let colors = avatarColors[colorIndex]
|
||||
let initial = String(name.prefix(1)).uppercased()
|
||||
|
||||
UIGraphicsBeginImageContextWithOptions(CGSize(width: size, height: size), false, 2.0)
|
||||
guard let ctx = UIGraphicsGetCurrentContext() else { return nil }
|
||||
|
||||
// Background circle.
|
||||
let bgColor = UIColor(
|
||||
red: CGFloat((colors.bg >> 16) & 0xFF) / 255,
|
||||
green: CGFloat((colors.bg >> 8) & 0xFF) / 255,
|
||||
blue: CGFloat(colors.bg & 0xFF) / 255,
|
||||
alpha: 1
|
||||
)
|
||||
ctx.setFillColor(bgColor.cgColor)
|
||||
ctx.fillEllipse(in: CGRect(x: 0, y: 0, width: size, height: size))
|
||||
|
||||
// Initial letter.
|
||||
let textColor = UIColor(
|
||||
red: CGFloat((colors.text >> 16) & 0xFF) / 255,
|
||||
green: CGFloat((colors.text >> 8) & 0xFF) / 255,
|
||||
blue: CGFloat(colors.text & 0xFF) / 255,
|
||||
alpha: 1
|
||||
)
|
||||
let font = UIFont.systemFont(ofSize: size * 0.38, weight: .bold)
|
||||
let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: textColor]
|
||||
let textSize = (initial as NSString).size(withAttributes: attrs)
|
||||
let textRect = CGRect(
|
||||
x: (size - textSize.width) / 2,
|
||||
y: (size - textSize.height) / 2,
|
||||
width: textSize.width,
|
||||
height: textSize.height
|
||||
)
|
||||
(initial as NSString).draw(in: textRect, withAttributes: attrs)
|
||||
|
||||
let image = UIGraphicsGetImageFromCurrentImageContext()
|
||||
UIGraphicsEndImageContext()
|
||||
|
||||
guard let pngData = image?.pngData() else { return nil }
|
||||
return INImage(imageData: pngData)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Android parity: extract sender key from multiple possible key names.
|
||||
|
||||
342
RosettaTests/CallRaceConditionTests.swift
Normal file
342
RosettaTests/CallRaceConditionTests.swift
Normal file
@@ -0,0 +1,342 @@
|
||||
import XCTest
|
||||
@testable import Rosetta
|
||||
|
||||
/// Tests for race condition fixes in CallKit + custom UI interaction.
|
||||
/// Covers: finishCall re-entrancy, double-accept prevention, phase guards,
|
||||
/// CallKit foreground suppression, and UUID lifecycle.
|
||||
@MainActor
|
||||
final class CallRaceConditionTests: XCTestCase {
|
||||
private let ownKey = "02-own-race-test"
|
||||
private let peerKey = "02-peer-race-test"
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
CallManager.shared.resetForSessionEnd()
|
||||
CallManager.shared.bindAccount(publicKey: ownKey)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
CallManager.shared.resetForSessionEnd()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - Helper
|
||||
|
||||
private func simulateIncomingCall() {
|
||||
let packet = PacketSignalPeer(
|
||||
src: peerKey,
|
||||
dst: ownKey,
|
||||
sharedPublic: "",
|
||||
signalType: .call,
|
||||
roomId: ""
|
||||
)
|
||||
CallManager.shared.testHandleSignalPacket(packet)
|
||||
}
|
||||
|
||||
// MARK: - finishCall Re-Entrancy Guard
|
||||
|
||||
func testFinishCallReEntrancyGuardResetsAfterCompletion() {
|
||||
simulateIncomingCall()
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .incoming)
|
||||
XCTAssertFalse(CallManager.shared.isFinishingCall)
|
||||
|
||||
CallManager.shared.endCall()
|
||||
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .idle)
|
||||
XCTAssertFalse(CallManager.shared.isFinishingCall, "isFinishingCall must reset after finishCall completes (defer block)")
|
||||
}
|
||||
|
||||
func testFinishCallNoOpWhenAlreadyIdle() {
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .idle)
|
||||
|
||||
// Should be a no-op — no crash, no state change
|
||||
CallManager.shared.finishCall(reason: "test", notifyPeer: false)
|
||||
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .idle)
|
||||
XCTAssertFalse(CallManager.shared.isFinishingCall)
|
||||
}
|
||||
|
||||
func testFinishCallCleansUpPhaseToIdle() {
|
||||
_ = CallManager.shared.startOutgoingCall(
|
||||
toPublicKey: peerKey,
|
||||
title: "Peer",
|
||||
username: "peer"
|
||||
)
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .outgoing)
|
||||
|
||||
CallManager.shared.finishCall(reason: nil, notifyPeer: false)
|
||||
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .idle)
|
||||
XCTAssertFalse(CallManager.shared.isFinishingCall)
|
||||
}
|
||||
|
||||
// MARK: - Double-Accept Prevention
|
||||
|
||||
func testDoubleAcceptReturnsNotIncomingOnSecondCall() {
|
||||
simulateIncomingCall()
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .incoming)
|
||||
|
||||
// First accept succeeds
|
||||
let result1 = CallManager.shared.acceptIncomingCall()
|
||||
XCTAssertEqual(result1, .started)
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .keyExchange)
|
||||
|
||||
// Second accept fails — phase is no longer .incoming
|
||||
let result2 = CallManager.shared.acceptIncomingCall()
|
||||
XCTAssertEqual(result2, .notIncoming, "Second accept must fail — phase already changed to keyExchange")
|
||||
}
|
||||
|
||||
func testAcceptAfterDeclineReturnsNotIncoming() {
|
||||
simulateIncomingCall()
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .incoming)
|
||||
|
||||
CallManager.shared.declineIncomingCall()
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .idle)
|
||||
|
||||
let result = CallManager.shared.acceptIncomingCall()
|
||||
XCTAssertEqual(result, .notIncoming, "Accept after decline must fail")
|
||||
}
|
||||
|
||||
func testAcceptAfterEndCallReturnsNotIncoming() {
|
||||
simulateIncomingCall()
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .incoming)
|
||||
|
||||
CallManager.shared.endCall()
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .idle)
|
||||
|
||||
let result = CallManager.shared.acceptIncomingCall()
|
||||
XCTAssertEqual(result, .notIncoming, "Accept after endCall must fail")
|
||||
}
|
||||
|
||||
func testAcceptWithNoAccountReturnsAccountNotBound() {
|
||||
CallManager.shared.bindAccount(publicKey: "")
|
||||
simulateIncomingCall()
|
||||
|
||||
// Incoming signal was rejected because ownPublicKey is empty,
|
||||
// so phase stays idle.
|
||||
let result = CallManager.shared.acceptIncomingCall()
|
||||
XCTAssertEqual(result, .notIncoming)
|
||||
}
|
||||
|
||||
// MARK: - Phase State Machine Guards
|
||||
|
||||
func testIncomingCallWhileBusySendsBusySignal() {
|
||||
// Start outgoing call first
|
||||
_ = CallManager.shared.startOutgoingCall(
|
||||
toPublicKey: peerKey,
|
||||
title: "Peer",
|
||||
username: "peer"
|
||||
)
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .outgoing)
|
||||
|
||||
// Second incoming call from different peer
|
||||
let packet = PacketSignalPeer(
|
||||
src: "02-another-peer",
|
||||
dst: ownKey,
|
||||
sharedPublic: "",
|
||||
signalType: .call,
|
||||
roomId: ""
|
||||
)
|
||||
CallManager.shared.testHandleSignalPacket(packet)
|
||||
|
||||
// Phase should still be outgoing (not replaced by incoming)
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .outgoing, "Existing call must not be replaced by new incoming")
|
||||
XCTAssertEqual(CallManager.shared.uiState.peerPublicKey, peerKey, "Peer key must remain from original call")
|
||||
}
|
||||
|
||||
func testEndCallBecauseBusySkipsAttachment() {
|
||||
_ = CallManager.shared.startOutgoingCall(
|
||||
toPublicKey: peerKey,
|
||||
title: "Peer",
|
||||
username: "peer"
|
||||
)
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .outgoing)
|
||||
|
||||
let busyPacket = PacketSignalPeer(
|
||||
src: "",
|
||||
dst: "",
|
||||
sharedPublic: "",
|
||||
signalType: .endCallBecauseBusy,
|
||||
roomId: ""
|
||||
)
|
||||
CallManager.shared.testHandleSignalPacket(busyPacket)
|
||||
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .idle)
|
||||
// skipAttachment=true prevents flooding chat with "Cancelled Call" bubbles
|
||||
}
|
||||
|
||||
func testEndCallFromPeerTransitionsToIdle() {
|
||||
simulateIncomingCall()
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .incoming)
|
||||
|
||||
let endPacket = PacketSignalPeer(
|
||||
src: "",
|
||||
dst: "",
|
||||
sharedPublic: "",
|
||||
signalType: .endCall,
|
||||
roomId: ""
|
||||
)
|
||||
CallManager.shared.testHandleSignalPacket(endPacket)
|
||||
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .idle)
|
||||
}
|
||||
|
||||
func testPeerDisconnectedTransitionsToIdle() {
|
||||
simulateIncomingCall()
|
||||
_ = CallManager.shared.acceptIncomingCall()
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .keyExchange)
|
||||
|
||||
let disconnectPacket = PacketSignalPeer(
|
||||
src: "",
|
||||
dst: "",
|
||||
sharedPublic: "",
|
||||
signalType: .endCallBecausePeerDisconnected,
|
||||
roomId: ""
|
||||
)
|
||||
CallManager.shared.testHandleSignalPacket(disconnectPacket)
|
||||
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .idle)
|
||||
}
|
||||
|
||||
// MARK: - WebRTC Phase Guard
|
||||
|
||||
func testSetCallActiveOnlyFromWebRtcExchange() {
|
||||
// Phase is idle — setCallActiveIfNeeded should be no-op
|
||||
CallManager.shared.setCallActiveIfNeeded()
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .idle, "setCallActive must not activate from idle")
|
||||
|
||||
// Phase is incoming — should also be no-op
|
||||
simulateIncomingCall()
|
||||
CallManager.shared.setCallActiveIfNeeded()
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .incoming, "setCallActive must not activate from incoming")
|
||||
}
|
||||
|
||||
func testSetCallActiveFromWebRtcExchangeSucceeds() {
|
||||
CallManager.shared.testSetUiState(
|
||||
CallUiState(
|
||||
phase: .webRtcExchange,
|
||||
peerPublicKey: peerKey,
|
||||
statusText: "Connecting..."
|
||||
)
|
||||
)
|
||||
|
||||
CallManager.shared.setCallActiveIfNeeded()
|
||||
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .active)
|
||||
}
|
||||
|
||||
// MARK: - CallKit UUID Lifecycle
|
||||
|
||||
func testCallKitUUIDClearedAfterFinishCall() {
|
||||
simulateIncomingCall()
|
||||
|
||||
CallManager.shared.endCall()
|
||||
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .idle)
|
||||
// CallKit UUID should be cleared after finishCall
|
||||
XCTAssertNil(CallKitManager.shared.currentCallUUID, "CallKit UUID must be cleared after call ends")
|
||||
}
|
||||
|
||||
func testCallKitUUIDSetForIncoming() {
|
||||
// CallKit is always reported for incoming calls (foreground and background).
|
||||
// Telegram parity: CallKit compact banner in foreground, full screen on lock screen.
|
||||
simulateIncomingCall()
|
||||
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .incoming)
|
||||
XCTAssertNotNil(
|
||||
CallKitManager.shared.currentCallUUID,
|
||||
"CallKit must be reported for all incoming calls"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - UI State Visibility
|
||||
|
||||
func testOverlayNotVisibleDuringIncoming() {
|
||||
// Telegram parity: CallKit handles incoming UI.
|
||||
// Custom overlay only appears after accept (phase != .incoming).
|
||||
simulateIncomingCall()
|
||||
|
||||
XCTAssertTrue(CallManager.shared.uiState.isVisible)
|
||||
// isFullScreenVisible is true but MainTabView guards with phase != .incoming
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .incoming)
|
||||
}
|
||||
|
||||
func testOverlayVisibleAfterAccept() {
|
||||
simulateIncomingCall()
|
||||
_ = CallManager.shared.acceptIncomingCall()
|
||||
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .keyExchange)
|
||||
XCTAssertTrue(CallManager.shared.uiState.isFullScreenVisible)
|
||||
XCTAssertFalse(CallManager.shared.uiState.isMinimized)
|
||||
}
|
||||
|
||||
func testOverlayHiddenAfterDecline() {
|
||||
simulateIncomingCall()
|
||||
CallManager.shared.declineIncomingCall()
|
||||
|
||||
XCTAssertFalse(CallManager.shared.uiState.isVisible)
|
||||
XCTAssertFalse(CallManager.shared.uiState.isFullScreenVisible)
|
||||
}
|
||||
|
||||
func testMinimizedBarVisibleAfterMinimize() {
|
||||
simulateIncomingCall()
|
||||
CallManager.shared.minimizeCall()
|
||||
|
||||
XCTAssertTrue(CallManager.shared.uiState.isVisible)
|
||||
XCTAssertTrue(CallManager.shared.uiState.isMinimized)
|
||||
XCTAssertFalse(CallManager.shared.uiState.isFullScreenVisible)
|
||||
}
|
||||
|
||||
func testExpandAfterMinimize() {
|
||||
simulateIncomingCall()
|
||||
CallManager.shared.minimizeCall()
|
||||
XCTAssertTrue(CallManager.shared.uiState.isMinimized)
|
||||
|
||||
CallManager.shared.expandCall()
|
||||
|
||||
XCTAssertFalse(CallManager.shared.uiState.isMinimized)
|
||||
XCTAssertTrue(CallManager.shared.uiState.isFullScreenVisible)
|
||||
}
|
||||
|
||||
// MARK: - Rapid State Transitions
|
||||
|
||||
func testRapidStartEndDoesNotCrash() {
|
||||
for _ in 0..<10 {
|
||||
_ = CallManager.shared.startOutgoingCall(
|
||||
toPublicKey: peerKey,
|
||||
title: "Peer",
|
||||
username: "peer"
|
||||
)
|
||||
CallManager.shared.endCall()
|
||||
}
|
||||
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .idle)
|
||||
XCTAssertFalse(CallManager.shared.isFinishingCall)
|
||||
}
|
||||
|
||||
func testRapidIncomingDeclineDoesNotCrash() {
|
||||
for _ in 0..<10 {
|
||||
simulateIncomingCall()
|
||||
CallManager.shared.declineIncomingCall()
|
||||
}
|
||||
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .idle)
|
||||
XCTAssertFalse(CallManager.shared.isFinishingCall)
|
||||
}
|
||||
|
||||
func testRapidAcceptDeclineCycle() {
|
||||
// Incoming → Accept → End → Incoming → Decline → repeat
|
||||
for i in 0..<5 {
|
||||
simulateIncomingCall()
|
||||
if i % 2 == 0 {
|
||||
_ = CallManager.shared.acceptIncomingCall()
|
||||
CallManager.shared.endCall()
|
||||
} else {
|
||||
CallManager.shared.declineIncomingCall()
|
||||
}
|
||||
}
|
||||
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .idle)
|
||||
XCTAssertFalse(CallManager.shared.isFinishingCall)
|
||||
}
|
||||
}
|
||||
158
RosettaTests/ForegroundNotificationTests.swift
Normal file
158
RosettaTests/ForegroundNotificationTests.swift
Normal file
@@ -0,0 +1,158 @@
|
||||
import Testing
|
||||
import UserNotifications
|
||||
@testable import Rosetta
|
||||
|
||||
// MARK: - Foreground Notification Suppression Tests
|
||||
|
||||
/// Tests for the in-app notification banner suppression logic (Telegram parity).
|
||||
/// System banners are always suppressed (`willPresent` returns `[]`).
|
||||
/// `InAppNotificationManager.shouldSuppress()` decides whether the
|
||||
/// custom in-app banner should be shown or hidden.
|
||||
struct ForegroundNotificationTests {
|
||||
|
||||
private func clearActiveDialogs() {
|
||||
for key in MessageRepository.shared.activeDialogKeys {
|
||||
MessageRepository.shared.setDialogActive(key, isActive: false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - System Banner Always Suppressed
|
||||
|
||||
@Test("System banner always suppressed — foregroundPresentationOptions returns []")
|
||||
func systemBannerAlwaysSuppressed() {
|
||||
clearActiveDialogs()
|
||||
let userInfo: [AnyHashable: Any] = ["dialog": "02aaa", "title": "Alice"]
|
||||
let options = AppDelegate.foregroundPresentationOptions(for: userInfo)
|
||||
#expect(options == [])
|
||||
}
|
||||
|
||||
@Test("System banner suppressed even for inactive chats")
|
||||
func systemBannerSuppressedInactive() {
|
||||
clearActiveDialogs()
|
||||
let userInfo: [AnyHashable: Any] = ["dialog": "02bbb"]
|
||||
#expect(AppDelegate.foregroundPresentationOptions(for: userInfo) == [])
|
||||
}
|
||||
|
||||
// MARK: - In-App Banner: Active Chat → Suppress
|
||||
|
||||
@Test("Active chat is suppressed by shouldSuppress")
|
||||
func activeChatSuppressed() {
|
||||
clearActiveDialogs()
|
||||
MessageRepository.shared.setDialogActive("02aaa", isActive: true)
|
||||
|
||||
#expect(InAppNotificationManager.shouldSuppress(senderKey: "02aaa") == true)
|
||||
|
||||
MessageRepository.shared.setDialogActive("02aaa", isActive: false)
|
||||
}
|
||||
|
||||
@Test("Inactive chat is NOT suppressed")
|
||||
func inactiveChatNotSuppressed() {
|
||||
clearActiveDialogs()
|
||||
#expect(InAppNotificationManager.shouldSuppress(senderKey: "02bbb") == false)
|
||||
}
|
||||
|
||||
@Test("Only active chat suppressed, other chats shown")
|
||||
func onlyActiveSuppressed() {
|
||||
clearActiveDialogs()
|
||||
MessageRepository.shared.setDialogActive("02aaa", isActive: true)
|
||||
|
||||
#expect(InAppNotificationManager.shouldSuppress(senderKey: "02aaa") == true)
|
||||
#expect(InAppNotificationManager.shouldSuppress(senderKey: "02bbb") == false)
|
||||
|
||||
MessageRepository.shared.setDialogActive("02aaa", isActive: false)
|
||||
}
|
||||
|
||||
// MARK: - Dialog Deactivation
|
||||
|
||||
@Test("After closing chat, notifications from it are no longer suppressed")
|
||||
func deactivatedChatNotSuppressed() {
|
||||
clearActiveDialogs()
|
||||
MessageRepository.shared.setDialogActive("02aaa", isActive: true)
|
||||
#expect(InAppNotificationManager.shouldSuppress(senderKey: "02aaa") == true)
|
||||
|
||||
MessageRepository.shared.setDialogActive("02aaa", isActive: false)
|
||||
#expect(InAppNotificationManager.shouldSuppress(senderKey: "02aaa") == false)
|
||||
}
|
||||
|
||||
// MARK: - Empty / Missing Sender Key
|
||||
|
||||
@Test("Empty sender key is suppressed (can't navigate to empty chat)")
|
||||
func emptySenderKeySuppressed() {
|
||||
clearActiveDialogs()
|
||||
#expect(InAppNotificationManager.shouldSuppress(senderKey: "") == true)
|
||||
}
|
||||
|
||||
// MARK: - Multiple Active Dialogs
|
||||
|
||||
@Test("Multiple active dialogs — each independently suppressed")
|
||||
func multipleActiveDialogs() {
|
||||
clearActiveDialogs()
|
||||
MessageRepository.shared.setDialogActive("02aaa", isActive: true)
|
||||
MessageRepository.shared.setDialogActive("02bbb", isActive: true)
|
||||
|
||||
#expect(InAppNotificationManager.shouldSuppress(senderKey: "02aaa") == true)
|
||||
#expect(InAppNotificationManager.shouldSuppress(senderKey: "02bbb") == true)
|
||||
#expect(InAppNotificationManager.shouldSuppress(senderKey: "02ccc") == false)
|
||||
|
||||
MessageRepository.shared.setDialogActive("02aaa", isActive: false)
|
||||
MessageRepository.shared.setDialogActive("02bbb", isActive: false)
|
||||
}
|
||||
|
||||
// MARK: - Muted Chat Suppression
|
||||
|
||||
@Test("Muted chat is suppressed")
|
||||
func mutedChatSuppressed() {
|
||||
clearActiveDialogs()
|
||||
// Set up muted chat in App Group
|
||||
let shared = UserDefaults(suiteName: "group.com.rosetta.dev")
|
||||
let originalMuted = shared?.stringArray(forKey: "muted_chats_keys")
|
||||
shared?.set(["02muted"], forKey: "muted_chats_keys")
|
||||
|
||||
#expect(InAppNotificationManager.shouldSuppress(senderKey: "02muted") == true)
|
||||
|
||||
// Restore
|
||||
shared?.set(originalMuted, forKey: "muted_chats_keys")
|
||||
}
|
||||
|
||||
@Test("Non-muted chat is NOT suppressed")
|
||||
func nonMutedChatNotSuppressed() {
|
||||
clearActiveDialogs()
|
||||
let shared = UserDefaults(suiteName: "group.com.rosetta.dev")
|
||||
let originalMuted = shared?.stringArray(forKey: "muted_chats_keys")
|
||||
shared?.set(["02other"], forKey: "muted_chats_keys")
|
||||
|
||||
#expect(InAppNotificationManager.shouldSuppress(senderKey: "02notmuted") == false)
|
||||
|
||||
shared?.set(originalMuted, forKey: "muted_chats_keys")
|
||||
}
|
||||
|
||||
// MARK: - Sender Key Extraction (AppDelegate)
|
||||
|
||||
@Test("extractSenderKey reads 'dialog' field first")
|
||||
func extractSenderKeyDialog() {
|
||||
let userInfo: [AnyHashable: Any] = ["dialog": "02aaa", "sender_public_key": "02bbb"]
|
||||
let key = AppDelegate.extractSenderKey(from: userInfo)
|
||||
#expect(key == "02aaa")
|
||||
}
|
||||
|
||||
@Test("extractSenderKey falls back to 'sender_public_key'")
|
||||
func extractSenderKeyFallback() {
|
||||
let userInfo: [AnyHashable: Any] = ["sender_public_key": "02ccc"]
|
||||
let key = AppDelegate.extractSenderKey(from: userInfo)
|
||||
#expect(key == "02ccc")
|
||||
}
|
||||
|
||||
@Test("extractSenderKey falls back to 'fromPublicKey'")
|
||||
func extractSenderKeyFromPublicKey() {
|
||||
let userInfo: [AnyHashable: Any] = ["fromPublicKey": "02ddd"]
|
||||
let key = AppDelegate.extractSenderKey(from: userInfo)
|
||||
#expect(key == "02ddd")
|
||||
}
|
||||
|
||||
@Test("extractSenderKey returns empty for missing keys")
|
||||
func extractSenderKeyEmpty() {
|
||||
let userInfo: [AnyHashable: Any] = ["type": "personal_message"]
|
||||
let key = AppDelegate.extractSenderKey(from: userInfo)
|
||||
#expect(key == "")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user