Fix push-навигация: stale pendingChatRoute вызывал переход в чужой чат при переключении табов
This commit is contained in:
@@ -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 = "";
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
231
RosettaTests/PendingChatRouteTests.swift
Normal file
231
RosettaTests/PendingChatRouteTests.swift
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user