Push-уведомления: Telegram-parity in-app баннер, threadIdentifier группировка и letter-avatar в NSE

This commit is contained in:
2026-04-01 18:33:59 +05:00
parent 79c5635715
commit 4be6761492
20 changed files with 1347 additions and 240 deletions

1
.gitignore vendored
View File

@@ -12,6 +12,7 @@ Telegram-iOS
AGENTS.md
voip.p12
CertificateSigningRequest.certSigningRequest
PhotosTransition
# Xcode
build/

View File

@@ -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()
let result = CallManager.shared.acceptIncomingCall()
if result == .started {
action.fulfill()
} else {
Self.logger.warning("CXAnswerCallAction failed: \(String(describing: result))")
action.fail()
}
}
}

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)
// MARK: - Header
ForwardPickerHeader(
isMultiSelect: isMultiSelect,
onClose: { dismiss() },
onSelect: {
withAnimation(.easeInOut(duration: 0.2)) {
isMultiSelect = true
}
}
)
// 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(RosettaColors.Adaptive.textSecondary)
.foregroundStyle(Color(white: 0.5))
Spacer()
}
} else {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(Array(dialogs.enumerated()), id: \.element.id) { index, dialog in
ForwardPickerRow(dialog: dialog) {
onSelect(ChatRoute(dialog: dialog))
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, 70)
.foregroundStyle(RosettaColors.Adaptive.divider)
.padding(.leading, 65)
.foregroundStyle(Color(red: 0x54 / 255.0, green: 0x54 / 255.0, blue: 0x58 / 255.0).opacity(0.55))
}
}
}
}
.scrollDismissesKeyboard(.interactively)
}
// 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) }
}
.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)
)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
ToolbarItem(placement: .principal) {
.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)
}
}
Spacer()
}
Text("Forward")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
.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)
ZStack(alignment: .leading) {
if searchText.isEmpty {
Text("Search")
.font(.system(size: 17))
.foregroundStyle(RosettaColors.Adaptive.text)
.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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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