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