diff --git a/.gitignore b/.gitignore index acddb13..b6f20c1 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ Telegram-iOS AGENTS.md voip.p12 CertificateSigningRequest.certSigningRequest +PhotosTransition # Xcode build/ diff --git a/Rosetta/Core/Services/CallKitManager.swift b/Rosetta/Core/Services/CallKitManager.swift index 246df50..9dc0507 100644 --- a/Rosetta/Core/Services/CallKitManager.swift +++ b/Rosetta/Core/Services/CallKitManager.swift @@ -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() + } } } diff --git a/Rosetta/Core/Services/CallManager+Runtime.swift b/Rosetta/Core/Services/CallManager+Runtime.swift index ad89486..2ff529a 100644 --- a/Rosetta/Core/Services/CallManager+Runtime.swift +++ b/Rosetta/Core/Services/CallManager+Runtime.swift @@ -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 diff --git a/Rosetta/Core/Services/CallManager.swift b/Rosetta/Core/Services/CallManager.swift index 9abac32..d7a398a 100644 --- a/Rosetta/Core/Services/CallManager.swift +++ b/Rosetta/Core/Services/CallManager.swift @@ -41,6 +41,8 @@ final class CallManager: NSObject, ObservableObject { var ringTimeoutTask: Task? var pendingMinimizeTask: Task? var liveActivity: Activity? + /// 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: diff --git a/Rosetta/Core/Services/InAppNotificationManager.swift b/Rosetta/Core/Services/InAppNotificationManager.swift new file mode 100644 index 0000000..5dc2928 --- /dev/null +++ b/Rosetta/Core/Services/InAppNotificationManager.swift @@ -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? + 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 + } +} diff --git a/Rosetta/DesignSystem/Components/InAppNotificationBanner.swift b/Rosetta/DesignSystem/Components/InAppNotificationBanner.swift new file mode 100644 index 0000000..bf64950 --- /dev/null +++ b/Rosetta/DesignSystem/Components/InAppNotificationBanner.swift @@ -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() } + } +} diff --git a/Rosetta/Features/Calls/CallActionButtons.swift b/Rosetta/Features/Calls/CallActionButtons.swift index 915430c..2df252b 100644 --- a/Rosetta/Features/Calls/CallActionButtons.swift +++ b/Rosetta/Features/Calls/CallActionButtons.swift @@ -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) diff --git a/Rosetta/Features/Chats/ChatDetail/BubbleContextMenuOverlay.swift b/Rosetta/Features/Chats/ChatDetail/BubbleContextMenuOverlay.swift index 1bc9e19..6a5a3e8 100644 --- a/Rosetta/Features/Chats/ChatDetail/BubbleContextMenuOverlay.swift +++ b/Rosetta/Features/Chats/ChatDetail/BubbleContextMenuOverlay.swift @@ -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 diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index f17df58..d8c96de 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -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) } diff --git a/Rosetta/Features/Chats/ChatDetail/ForwardChatPickerView.swift b/Rosetta/Features/Chats/ChatDetail/ForwardChatPickerView.swift index 0afc69a..9aed360 100644 --- a/Rosetta/Features/Chats/ChatDetail/ForwardChatPickerView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ForwardChatPickerView.swift @@ -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 = [] - /// 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) + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift b/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift index 293597b..acb7921 100644 --- a/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift +++ b/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift @@ -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 { } } } - } + diff --git a/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift b/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift index 1dd76dc..ca60de0 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift @@ -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 } diff --git a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift index 70029ae..23d0f59 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift @@ -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 diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index 8cfe2e9..bba62e4 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -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) } diff --git a/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift b/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift index 38d4def..4d19660 100644 --- a/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift +++ b/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift @@ -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 { diff --git a/Rosetta/Features/MainTabView.swift b/Rosetta/Features/MainTabView.swift index 13426be..5313a13 100644 --- a/Rosetta/Features/MainTabView.swift +++ b/Rosetta/Features/MainTabView.swift @@ -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 diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index d817de6..7b196b3 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -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() diff --git a/RosettaNotificationService/NotificationService.swift b/RosettaNotificationService/NotificationService.swift index 7cf8f75..2d48573 100644 --- a/RosettaNotificationService/NotificationService.swift +++ b/RosettaNotificationService/NotificationService.swift @@ -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. diff --git a/RosettaTests/CallRaceConditionTests.swift b/RosettaTests/CallRaceConditionTests.swift new file mode 100644 index 0000000..7d6386d --- /dev/null +++ b/RosettaTests/CallRaceConditionTests.swift @@ -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) + } +} diff --git a/RosettaTests/ForegroundNotificationTests.swift b/RosettaTests/ForegroundNotificationTests.swift new file mode 100644 index 0000000..8127e60 --- /dev/null +++ b/RosettaTests/ForegroundNotificationTests.swift @@ -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 == "") + } +}