Fix push-навигация: stale pendingChatRoute вызывал переход в чужой чат при переключении табов
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user