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

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