diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index b7e60d4..abebac0 100644 --- a/Rosetta.xcodeproj/project.pbxproj +++ b/Rosetta.xcodeproj/project.pbxproj @@ -613,7 +613,7 @@ CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 29; + CURRENT_PROJECT_VERSION = 30; DEVELOPMENT_TEAM = QN8Z263QGX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -629,7 +629,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.8; + MARKETING_VERSION = 1.2.9; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -653,7 +653,7 @@ CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 29; + CURRENT_PROJECT_VERSION = 30; DEVELOPMENT_TEAM = QN8Z263QGX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -669,7 +669,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.8; + MARKETING_VERSION = 1.2.9; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Rosetta/Core/Utils/ReleaseNotes.swift b/Rosetta/Core/Utils/ReleaseNotes.swift index c4aeac8..b2acb52 100644 --- a/Rosetta/Core/Utils/ReleaseNotes.swift +++ b/Rosetta/Core/Utils/ReleaseNotes.swift @@ -11,14 +11,23 @@ enum ReleaseNotes { Entry( version: appVersion, body: """ - **Чат — анимации и навигация** - Telegram-style анимация появления новых сообщений (spring slide-up + alpha fade). Разделители дат со sticky-поведением и push-переходом между секциями. Reply-to-reply с подсветкой сообщения при навигации по реплаю. + **Звонки — CallKit и PushKit** + Интеграция с CallKit: входящие звонки отображаются на экране блокировки и Dynamic Island. PushKit для мгновенной доставки вызовов. Аватарки в списке звонков. Live Activity на экране блокировки. - **Звонки — минимизированная панель** - Telegram-style call-бар при активном звонке: зелёный градиент, навигационная панель сдвигается вниз. Тап по панели — возврат на полный экран звонка. + **Пересылка сообщений — Telegram-parity** + Полностью переработан UI пересылки: правильный размер бабла, аватарка отправителя с инициалами, корректные отступы текста и таймстампа. Пересылка без перезаливки файлов на CDN. + + **Чат — анимации и навигация** + Мгновенное появление отправленных сообщений (Telegram-parity spring). Разделители дат со sticky-поведением. Reply-to-reply с подсветкой и навигацией. Свайп-реплай с Telegram-эффектами. Корректное отображение эмодзи с Android/Desktop. + + **Пуш-уведомления** + Data-only пуши: мгновенная обработка прочтений, мут-проверка групп, имя отправителя из контактов. + + **Кроссплатформенный паритет** + 10 фиксов совместимости: reply-бар, файлы, аватар, blurhash, per-attachment транспорт. Ограничения реплая/форварда синхронизированы с Desktop. **Стабильность** - Фикс скролла реплай-сообщений под композер при отправке. Блокировка восстановления клавиатуры при свайп-бэк. Скип read receipt для системных аккаунтов. Фикс race condition свайп-минимизации call bar. Пустой чат: glass-подложка и composer на iOS < 26. + Фикс клавиатуры при свайп-бэк. Race condition свайп-минимизации call bar. Скролл реплай-сообщений под композер. Пустой чат: glass-подложка на iOS < 26. """ ) ] diff --git a/Rosetta/Features/Chats/ChatDetail/ForwardChatPickerView.swift b/Rosetta/Features/Chats/ChatDetail/ForwardChatPickerView.swift index 56c1eb8..0afc69a 100644 --- a/Rosetta/Features/Chats/ChatDetail/ForwardChatPickerView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ForwardChatPickerView.swift @@ -1,71 +1,186 @@ import SwiftUI -/// Sheet for picking a chat to forward a message to. -/// Shows all existing dialogs sorted by last message time. +/// Telegram-style forward picker sheet. +/// Shows search bar + chat list with Saved Messages always first. struct ForwardChatPickerView: View { let onSelect: (ChatRoute) -> Void @Environment(\.dismiss) private var dismiss + @State private var searchText = "" - /// Android parity: system accounts (Updates, Safe) excluded from forward picker. - /// Saved Messages allowed (forward to self). + /// Filtered + sorted dialogs: Saved Messages first, then pinned, then recent. private var dialogs: [Dialog] { - DialogRepository.shared.sortedDialogs.filter { + let all = DialogRepository.shared.sortedDialogs.filter { ($0.iHaveSent || $0.isSavedMessages) && !SystemAccounts.isSystemAccount($0.opponentKey) } + + let filtered: [Dialog] + if searchText.trimmingCharacters(in: .whitespaces).isEmpty { + filtered = all + } else { + let query = searchText.lowercased() + filtered = all.filter { dialog in + dialog.opponentTitle.lowercased().contains(query) || + dialog.opponentUsername.lowercased().contains(query) || + (dialog.isSavedMessages && "saved messages".contains(query)) + } + } + + // Saved Messages always first + var saved: Dialog? + var rest: [Dialog] = [] + for dialog in filtered { + if dialog.isSavedMessages { + saved = dialog + } else { + rest.append(dialog) + } + } + if let saved { + return [saved] + rest + } + return rest } var body: some View { NavigationStack { - List(dialogs) { dialog in - Button { - onSelect(ChatRoute(dialog: dialog)) - } label: { - HStack(spacing: 12) { - AvatarView( - initials: dialog.initials, - colorIndex: dialog.avatarColorIndex, - size: 42, - isSavedMessages: dialog.isSavedMessages - ) - - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 4) { - Text(dialog.isSavedMessages ? "Saved Messages" : dialog.opponentTitle) - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(RosettaColors.Adaptive.text) - .lineLimit(1) - - if dialog.effectiveVerified > 0 && !dialog.isSavedMessages { - VerifiedBadge(verified: dialog.effectiveVerified, size: 14) - } - } - - if !dialog.opponentUsername.isEmpty && !dialog.isSavedMessages { - Text("@\(dialog.opponentUsername)") - .font(.system(size: 13, weight: .regular)) - .foregroundStyle(RosettaColors.Adaptive.textSecondary) - .lineLimit(1) - } - } + 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() } - .contentShape(Rectangle()) + } 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) + } + } + } + } + .scrollDismissesKeyboard(.interactively) } - .listRowBackground(RosettaColors.Dark.surface) } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .background(RosettaColors.Dark.background) - .navigationTitle("Forward to...") + .background(RosettaColors.Dark.background.ignoresSafeArea()) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } + Button { dismiss() } label: { + Image(systemName: "xmark") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.text) + } + } + ToolbarItem(placement: .principal) { + Text("Forward") + .font(.system(size: 17, weight: .semibold)) .foregroundStyle(RosettaColors.Adaptive.text) } } + .toolbarBackground(RosettaColors.Dark.background, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) } .preferredColorScheme(.dark) } } + +// MARK: - Search Bar + +private struct ForwardPickerSearchBar: View { + @Binding var searchText: String + @FocusState private var isFocused: Bool + + var body: some View { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(Color.gray) + + TextField("Search", text: $searchText) + .font(.system(size: 17)) + .foregroundStyle(RosettaColors.Adaptive.text) + .focused($isFocused) + .submitLabel(.search) + + if !searchText.isEmpty { + Button { + searchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 15)) + .foregroundStyle(Color.gray) + } + } + } + .padding(.horizontal, 12) + .frame(height: 42) + .background { + RoundedRectangle(cornerRadius: 21, style: .continuous) + .fill(RosettaColors.Adaptive.backgroundSecondary) + } + } +} + +// MARK: - Row + +private struct ForwardPickerRow: View { + let dialog: Dialog + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack(spacing: 12) { + ForwardPickerRowAvatar(dialog: dialog) + + HStack(spacing: 4) { + Text(dialog.isSavedMessages ? "Saved Messages" : dialog.opponentTitle) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.text) + .lineLimit(1) + + if dialog.effectiveVerified > 0 && !dialog.isSavedMessages { + VerifiedBadge(verified: dialog.effectiveVerified, size: 14) + } + } + + Spacer() + } + .padding(.horizontal, 16) + .frame(height: 56) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} + +// MARK: - Row Avatar (observation-isolated) + +private struct ForwardPickerRowAvatar: View { + let dialog: Dialog + + var body: some View { + let _ = AvatarRepository.shared.avatarVersion + AvatarView( + initials: dialog.initials, + colorIndex: dialog.avatarColorIndex, + size: 42, + isSavedMessages: dialog.isSavedMessages, + image: dialog.isSavedMessages ? nil : AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey) + ) + } +} diff --git a/Rosetta/Features/Chats/ChatList/ChatListView.swift b/Rosetta/Features/Chats/ChatList/ChatListView.swift index 4507b47..1e92b03 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListView.swift @@ -1,5 +1,6 @@ import Combine import SwiftUI +import UIKit // MARK: - Navigation State (survives parent re-renders) @@ -104,23 +105,22 @@ struct ChatListView: View { .tint(RosettaColors.figmaBlue) .onReceive(NotificationCenter.default.publisher(for: .openChatFromNotification)) { notification in guard let route = notification.object as? ChatRoute else { return } - // Navigate to the chat from push notification tap + // Navigate to the chat from push notification tap (fast path) navigationState.path = [route] - // Clear pending route — consumed by onReceive (fast path) AppDelegate.pendingChatRoute = nil + AppDelegate.pendingChatRouteTimestamp = nil } .onAppear { - // Fallback: consume pending route if .onReceive missed it. - // Handles terminated app (ChatListView didn't exist when notification was posted) - // and background app (Combine subscription may not fire during app resume). - if let route = AppDelegate.pendingChatRoute { - AppDelegate.pendingChatRoute = nil - // Small delay to let NavigationStack settle after view creation - Task { @MainActor in - try? await Task.sleep(nanoseconds: 100_000_000) // 100ms - navigationState.path = [route] - } - } + // Cold start fallback: ChatListView didn't exist when notification was posted. + // Expiry guard (3s) prevents stale routes from firing on tab switches — + // critical for iOS < 26 pager (ZStack opacity 0→1 re-fires .onAppear). + consumePendingRouteIfFresh() + } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in + // Background→foreground fallback: covers edge cases where .onReceive + // subscription hasn't re-activated after background→foreground transition. + // Harmless if .onReceive already consumed the route (statics are nil). + consumePendingRouteIfFresh() } } @@ -132,6 +132,16 @@ struct ChatListView: View { searchText = "" viewModel.setSearchQuery("") } + + /// Consume pending notification route only if it was set within the last 3 seconds. + /// Prevents stale routes (from failed .onReceive) from being consumed on tab switches. + private func consumePendingRouteIfFresh() { + guard let route = AppDelegate.consumeFreshPendingRoute() else { return } + Task { @MainActor in + try? await Task.sleep(nanoseconds: 100_000_000) // 100ms for NavigationStack settle + navigationState.path = [route] + } + } } // MARK: - Custom Search Bar diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index 781f9b5..d817de6 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -11,10 +11,31 @@ import UserNotifications final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate, MessagingDelegate { - /// Pending chat route from notification tap — consumed by ChatListView on appear. - /// Handles both terminated app (notification posted before ChatListView exists) - /// and background app (fallback if .onReceive misses the synchronous post). + /// Pending chat route from notification tap — consumed by ChatListView. + /// Handles terminated app (notification posted before ChatListView exists) + /// and background app (didBecomeActiveNotification fallback). + /// Timestamp prevents stale routes from being consumed on tab switches. static var pendingChatRoute: ChatRoute? + static var pendingChatRouteTimestamp: Date? + + /// Max age (seconds) for a pending route to be considered fresh. + static let pendingRouteExpirySeconds: TimeInterval = 3.0 + + /// Consume pending notification route only if it was set recently. + /// Returns the route if fresh (< `pendingRouteExpirySeconds`), nil otherwise. + /// Always clears both statics regardless of freshness. + static func consumeFreshPendingRoute() -> ChatRoute? { + defer { + pendingChatRoute = nil + pendingChatRouteTimestamp = nil + } + guard let route = pendingChatRoute, + let ts = pendingChatRouteTimestamp, + Date().timeIntervalSince(ts) < pendingRouteExpirySeconds else { + return nil + } + return route + } /// PushKit registry — must be retained for VoIP push token delivery. private var voipRegistry: PKPushRegistry? @@ -383,6 +404,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent // where ChatListView doesn't exist yet, and background app case // where .onReceive might miss the synchronous post. Self.pendingChatRoute = route + Self.pendingChatRouteTimestamp = Date() NotificationCenter.default.post( name: .openChatFromNotification, object: route diff --git a/RosettaTests/PendingChatRouteTests.swift b/RosettaTests/PendingChatRouteTests.swift new file mode 100644 index 0000000..d1fc76d --- /dev/null +++ b/RosettaTests/PendingChatRouteTests.swift @@ -0,0 +1,231 @@ +import Testing +@testable import Rosetta + +// MARK: - Pending Chat Route Expiry Tests + +/// Tests for the push notification → chat navigation fix. +/// Bug: stale `pendingChatRoute` was consumed on tab switches (`.onAppear`) +/// instead of only on cold start / foreground resume. +/// Fix: `consumeFreshPendingRoute()` checks timestamp < 3 seconds. +struct PendingChatRouteExpiryTests { + + private let routeA = ChatRoute(publicKey: "02aaa", title: "Alice", username: "", verified: 0) + private let routeB = ChatRoute(publicKey: "02bbb", title: "Bob", username: "", verified: 0) + + private func clearStatics() { + AppDelegate.pendingChatRoute = nil + AppDelegate.pendingChatRouteTimestamp = nil + } + + // MARK: - Fresh Route Consumption + + @Test("Fresh route (just set) is consumed successfully") + func freshRouteConsumed() { + clearStatics() + AppDelegate.pendingChatRoute = routeA + AppDelegate.pendingChatRouteTimestamp = Date() + + let result = AppDelegate.consumeFreshPendingRoute() + #expect(result != nil) + #expect(result?.publicKey == "02aaa") + #expect(result?.title == "Alice") + } + + @Test("After consumption, both statics are nil") + func staticsNilAfterConsumption() { + clearStatics() + AppDelegate.pendingChatRoute = routeA + AppDelegate.pendingChatRouteTimestamp = Date() + + _ = AppDelegate.consumeFreshPendingRoute() + #expect(AppDelegate.pendingChatRoute == nil) + #expect(AppDelegate.pendingChatRouteTimestamp == nil) + } + + @Test("Second consumption returns nil (idempotent)") + func doubleConsumptionReturnsNil() { + clearStatics() + AppDelegate.pendingChatRoute = routeA + AppDelegate.pendingChatRouteTimestamp = Date() + + let first = AppDelegate.consumeFreshPendingRoute() + let second = AppDelegate.consumeFreshPendingRoute() + #expect(first != nil) + #expect(second == nil) + } + + // MARK: - Stale Route Rejection + + @Test("Stale route (5s old) is rejected and cleared") + func staleRouteRejected() { + clearStatics() + AppDelegate.pendingChatRoute = routeA + AppDelegate.pendingChatRouteTimestamp = Date().addingTimeInterval(-5) + + let result = AppDelegate.consumeFreshPendingRoute() + #expect(result == nil) + // Both statics must be cleared even on rejection + #expect(AppDelegate.pendingChatRoute == nil) + #expect(AppDelegate.pendingChatRouteTimestamp == nil) + } + + @Test("Route exactly at expiry boundary (3s) is rejected") + func routeAtExpiryBoundary() { + clearStatics() + AppDelegate.pendingChatRoute = routeA + AppDelegate.pendingChatRouteTimestamp = Date().addingTimeInterval(-3.0) + + let result = AppDelegate.consumeFreshPendingRoute() + #expect(result == nil) + } + + @Test("Route just before expiry (2.9s) is consumed") + func routeJustBeforeExpiry() { + clearStatics() + AppDelegate.pendingChatRoute = routeA + AppDelegate.pendingChatRouteTimestamp = Date().addingTimeInterval(-2.9) + + let result = AppDelegate.consumeFreshPendingRoute() + #expect(result != nil) + #expect(result?.publicKey == "02aaa") + } + + @Test("Very old route (60s) is rejected") + func veryOldRouteRejected() { + clearStatics() + AppDelegate.pendingChatRoute = routeA + AppDelegate.pendingChatRouteTimestamp = Date().addingTimeInterval(-60) + + let result = AppDelegate.consumeFreshPendingRoute() + #expect(result == nil) + } + + // MARK: - Missing Data + + @Test("Route without timestamp is rejected and cleared") + func routeWithoutTimestamp() { + clearStatics() + AppDelegate.pendingChatRoute = routeA + AppDelegate.pendingChatRouteTimestamp = nil + + let result = AppDelegate.consumeFreshPendingRoute() + #expect(result == nil) + #expect(AppDelegate.pendingChatRoute == nil) + } + + @Test("Timestamp without route is rejected and cleared") + func timestampWithoutRoute() { + clearStatics() + AppDelegate.pendingChatRoute = nil + AppDelegate.pendingChatRouteTimestamp = Date() + + let result = AppDelegate.consumeFreshPendingRoute() + #expect(result == nil) + #expect(AppDelegate.pendingChatRouteTimestamp == nil) + } + + @Test("Both nil — returns nil, no crash") + func bothNil() { + clearStatics() + let result = AppDelegate.consumeFreshPendingRoute() + #expect(result == nil) + } + + // MARK: - Route Overwrite (rapid notification taps) + + @Test("Second notification overwrites first — only second is consumed") + func overwriteWithSecondNotification() { + clearStatics() + // First notification + AppDelegate.pendingChatRoute = routeA + AppDelegate.pendingChatRouteTimestamp = Date() + // Second notification immediately after (overwrites) + AppDelegate.pendingChatRoute = routeB + AppDelegate.pendingChatRouteTimestamp = Date() + + let result = AppDelegate.consumeFreshPendingRoute() + #expect(result != nil) + #expect(result?.publicKey == "02bbb") + #expect(result?.title == "Bob") + } + + // MARK: - Expiry Constant + + @Test("Expiry constant is 3 seconds") + func expiryConstantValue() { + #expect(AppDelegate.pendingRouteExpirySeconds == 3.0) + } +} + +// MARK: - Tab Switch Scenario Tests + +/// Simulates the exact bug scenario: +/// 1. Route set from notification tap +/// 2. .onReceive fails to consume (simulated by skipping) +/// 3. Tab switch triggers .onAppear → stale route must NOT navigate +struct TabSwitchScenarioTests { + + private let route = ChatRoute(publicKey: "02ccc", title: "Charlie", username: "", verified: 0) + + private func clearStatics() { + AppDelegate.pendingChatRoute = nil + AppDelegate.pendingChatRouteTimestamp = nil + } + + @Test("Stale route from 10s ago is NOT consumed on tab switch") + func staleRouteOnTabSwitch() { + clearStatics() + // Simulate: notification tapped 10 seconds ago, .onReceive missed it + AppDelegate.pendingChatRoute = route + AppDelegate.pendingChatRouteTimestamp = Date().addingTimeInterval(-10) + + // Simulate: .onAppear fires on tab switch + let consumed = AppDelegate.consumeFreshPendingRoute() + #expect(consumed == nil, "Stale route must NOT be consumed on tab switch") + #expect(AppDelegate.pendingChatRoute == nil, "Stale route must be cleared") + } + + @Test("Fresh route from notification tap IS consumed on cold start .onAppear") + func freshRouteOnColdStart() { + clearStatics() + // Simulate: notification tapped just now, app is launching + AppDelegate.pendingChatRoute = route + AppDelegate.pendingChatRouteTimestamp = Date() + + // Simulate: .onAppear fires on cold start (< 3s since notification tap) + let consumed = AppDelegate.consumeFreshPendingRoute() + #expect(consumed != nil, "Fresh route must be consumed on cold start") + #expect(consumed?.publicKey == "02ccc") + } + + @Test("Fresh route consumed by .onReceive → didBecomeActive finds nil") + func onReceiveConsumesThenDidBecomeActiveNoOp() { + clearStatics() + // Simulate: notification posted + .onReceive fires (primary path) + AppDelegate.pendingChatRoute = route + AppDelegate.pendingChatRouteTimestamp = Date() + + // .onReceive consumes (simulated by direct clear, same as ChatListView line 110-111) + AppDelegate.pendingChatRoute = nil + AppDelegate.pendingChatRouteTimestamp = nil + + // didBecomeActiveNotification fires after + let consumed = AppDelegate.consumeFreshPendingRoute() + #expect(consumed == nil, "didBecomeActive must be no-op after .onReceive consumed") + } + + @Test("Route set → first consumeFresh consumes → second consumeFresh returns nil") + func sequentialConsumption() { + clearStatics() + AppDelegate.pendingChatRoute = route + AppDelegate.pendingChatRouteTimestamp = Date() + + // First call: .onAppear on cold start + let first = AppDelegate.consumeFreshPendingRoute() + // Second call: didBecomeActiveNotification a moment later + let second = AppDelegate.consumeFreshPendingRoute() + + #expect(first != nil) + #expect(second == nil, "Only the first caller should get the route") + } +}