Fix push-навигация: stale pendingChatRoute вызывал переход в чужой чат при переключении табов

This commit is contained in:
2026-04-01 15:32:08 +05:00
parent 8f69781a66
commit 79c5635715
6 changed files with 455 additions and 68 deletions

View File

@@ -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 = "";

View File

@@ -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.
"""
)
]

View File

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

View File

@@ -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 01 re-fires .onAppear).
consumePendingRouteIfFresh()
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
// Backgroundforeground fallback: covers edge cases where .onReceive
// subscription hasn't re-activated after backgroundforeground 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

View File

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

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