Уведомления CarPlay, панель вложений с Lottie, фикс reply preview, плавная анимация клавиатуры, стабильность WebSocket
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import SwiftUI
|
||||
import Photos
|
||||
import PhotosUI
|
||||
import Lottie
|
||||
|
||||
// MARK: - AttachmentPanelView
|
||||
|
||||
@@ -33,6 +34,9 @@ struct AttachmentPanelView: View {
|
||||
@State private var capturedImage: UIImage?
|
||||
@State private var captionText: String = ""
|
||||
@State private var previewAsset: IdentifiableAsset?
|
||||
/// Tab widths/origins for sliding selection indicator (RosettaTabBar parity).
|
||||
@State private var tabWidths: [AttachmentTab: CGFloat] = [:]
|
||||
@State private var tabOrigins: [AttachmentTab: CGFloat] = [:]
|
||||
|
||||
private var hasSelection: Bool { !selectedAssets.isEmpty }
|
||||
|
||||
@@ -60,8 +64,7 @@ struct AttachmentPanelView: View {
|
||||
case .file:
|
||||
fileTabContent
|
||||
case .avatar:
|
||||
// Avatar is an action tab — handled in tabButton tap
|
||||
Spacer()
|
||||
avatarTabContent
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
@@ -104,6 +107,8 @@ struct AttachmentPanelView: View {
|
||||
)
|
||||
.background(TransparentFullScreenBackground())
|
||||
}
|
||||
.onPreferenceChange(AttachTabWidthKey.self) { tabWidths.merge($0) { _, new in new } }
|
||||
.onPreferenceChange(AttachTabOriginKey.self) { tabOrigins.merge($0) { _, new in new } }
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.hidden)
|
||||
.attachmentCornerRadius(20)
|
||||
@@ -200,19 +205,85 @@ struct AttachmentPanelView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Avatar Tab Content (Android parity: AttachAlertAvatarLayout)
|
||||
|
||||
/// Dedicated avatar tab with Lottie animation and "Send Avatar" button.
|
||||
/// Android: `AttachAlertAvatarLayout.kt` — looping Lottie + title + subtitle + button.
|
||||
private var avatarTabContent: some View {
|
||||
VStack(spacing: 16) {
|
||||
Spacer()
|
||||
|
||||
// Lottie animation (Android: avatar.json, 100×100dp, infinite loop)
|
||||
LottieView(
|
||||
animationName: "avatar",
|
||||
loopMode: .loop,
|
||||
animationSpeed: 1.0
|
||||
)
|
||||
.frame(width: 100, height: 100)
|
||||
|
||||
// Title
|
||||
Text("Send Avatar")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
// Subtitle
|
||||
Text("Share your profile avatar\nwith this contact")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
// Send button (capsule style matching File tab's "Browse Files" button)
|
||||
Button {
|
||||
if hasAvatar {
|
||||
onSendAvatar()
|
||||
dismiss()
|
||||
} else {
|
||||
dismiss()
|
||||
onSetAvatar?()
|
||||
}
|
||||
} label: {
|
||||
Text(hasAvatar ? "Send Avatar" : "Set Avatar")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color(hex: 0x008BFF), in: Capsule())
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Reserve space for tab bar so content doesn't get clipped
|
||||
Color.clear.frame(height: 70)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
// MARK: - File Tab Content
|
||||
|
||||
private var fileTabContent: some View {
|
||||
VStack(spacing: 20) {
|
||||
VStack(spacing: 16) {
|
||||
Spacer()
|
||||
Image(systemName: "doc.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.white.opacity(0.3))
|
||||
|
||||
// Lottie animation (matching avatar tab layout)
|
||||
LottieView(
|
||||
animationName: "file_folder",
|
||||
loopMode: .loop,
|
||||
animationSpeed: 1.0
|
||||
)
|
||||
.frame(width: 100, height: 100)
|
||||
|
||||
// Title
|
||||
Text("Send File")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
// Subtitle
|
||||
Text("Select a file to send")
|
||||
.font(.system(size: 16))
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
// Browse button (capsule style matching avatar tab)
|
||||
Button {
|
||||
showFilePicker = true
|
||||
} label: {
|
||||
@@ -225,6 +296,9 @@ struct AttachmentPanelView: View {
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Reserve space for tab bar so content doesn't get clipped
|
||||
Color.clear.frame(height: 70)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.task {
|
||||
@@ -315,28 +389,66 @@ struct AttachmentPanelView: View {
|
||||
TelegramGlassRoundedRect(cornerRadius: 21)
|
||||
}
|
||||
|
||||
// MARK: - Tab Bar (Figma: glass capsule, 3 tabs)
|
||||
// MARK: - Tab Bar (RosettaTabBar parity: glass capsule + sliding indicator)
|
||||
|
||||
/// Glass capsule tab bar matching RosettaTabBar pattern.
|
||||
/// Glass capsule tab bar matching RosettaTabBar pattern exactly.
|
||||
/// Tabs: Gallery | File | Avatar.
|
||||
/// Colors from RosettaTabBar: selected=#008BFF, unselected=white.
|
||||
/// Background: .regularMaterial (iOS < 26) / .glassEffect (iOS 26+).
|
||||
/// Selection indicator: sliding glass pill behind selected tab (spring animation).
|
||||
private var tabBar: some View {
|
||||
HStack(spacing: 0) {
|
||||
tabButton(.gallery, icon: "photo.fill", label: "Gallery")
|
||||
.background(tabWidthReader(.gallery))
|
||||
tabButton(.file, icon: "doc.fill", label: "File")
|
||||
.background(tabWidthReader(.file))
|
||||
tabButton(.avatar, icon: "person.crop.circle.fill", label: "Avatar")
|
||||
.background(tabWidthReader(.avatar))
|
||||
}
|
||||
.padding(4)
|
||||
.background { tabBarBackground }
|
||||
.coordinateSpace(name: "attachTabBar")
|
||||
.background(alignment: .leading) {
|
||||
// Sliding selection indicator (RosettaTabBar parity)
|
||||
attachmentSelectionIndicator
|
||||
}
|
||||
.background { TelegramGlassCapsule() }
|
||||
.clipShape(Capsule())
|
||||
.contentShape(Capsule())
|
||||
.tabBarShadow()
|
||||
}
|
||||
|
||||
/// Glass background matching RosettaTabBar (lines 136–149).
|
||||
private var tabBarBackground: some View {
|
||||
TelegramGlassCapsule()
|
||||
/// Reads tab width and origin for selection indicator positioning.
|
||||
private func tabWidthReader(_ tab: AttachmentTab) -> some View {
|
||||
GeometryReader { geo in
|
||||
Color.clear.preference(
|
||||
key: AttachTabOriginKey.self,
|
||||
value: [tab: geo.frame(in: .named("attachTabBar")).origin.x]
|
||||
)
|
||||
.preference(
|
||||
key: AttachTabWidthKey.self,
|
||||
value: [tab: geo.size.width]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sliding glass pill behind the selected tab (matches RosettaTabBar).
|
||||
@ViewBuilder
|
||||
private var attachmentSelectionIndicator: some View {
|
||||
let width = tabWidths[selectedTab] ?? 0
|
||||
let xOffset = tabOrigins[selectedTab] ?? 0
|
||||
|
||||
if #available(iOS 26, *) {
|
||||
Capsule().fill(.clear)
|
||||
.glassEffect(.regular, in: .capsule)
|
||||
.allowsHitTesting(false)
|
||||
.frame(width: width)
|
||||
.offset(x: xOffset)
|
||||
.animation(.spring(response: 0.34, dampingFraction: 0.82), value: selectedTab)
|
||||
} else {
|
||||
Capsule().fill(.thinMaterial)
|
||||
.frame(width: max(0, width - 4))
|
||||
.padding(.vertical, 4)
|
||||
.offset(x: xOffset + 2)
|
||||
.animation(.spring(response: 0.34, dampingFraction: 0.82), value: selectedTab)
|
||||
}
|
||||
}
|
||||
|
||||
/// Individual tab button matching RosettaTabBar dimensions exactly.
|
||||
@@ -345,20 +457,8 @@ struct AttachmentPanelView: View {
|
||||
let isSelected = selectedTab == tab
|
||||
|
||||
return Button {
|
||||
if tab == .avatar {
|
||||
if hasAvatar {
|
||||
onSendAvatar()
|
||||
dismiss()
|
||||
} else {
|
||||
// No avatar set — offer to set one
|
||||
dismiss()
|
||||
onSetAvatar?()
|
||||
}
|
||||
return
|
||||
} else {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
selectedTab = tab
|
||||
}
|
||||
withAnimation(.spring(response: 0.34, dampingFraction: 0.82)) {
|
||||
selectedTab = tab
|
||||
}
|
||||
} label: {
|
||||
VStack(spacing: 2) {
|
||||
@@ -372,11 +472,6 @@ struct AttachmentPanelView: View {
|
||||
.foregroundStyle(isSelected ? Color(hex: 0x008BFF) : .white)
|
||||
.frame(minWidth: 66, maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
.background {
|
||||
if isSelected {
|
||||
TelegramGlassCapsule()
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -457,6 +552,22 @@ private enum AttachmentTab: Hashable {
|
||||
case avatar
|
||||
}
|
||||
|
||||
// MARK: - Tab Bar Preference Keys (selection indicator positioning)
|
||||
|
||||
private struct AttachTabWidthKey: PreferenceKey {
|
||||
static var defaultValue: [AttachmentTab: CGFloat] = [:]
|
||||
static func reduce(value: inout [AttachmentTab: CGFloat], nextValue: () -> [AttachmentTab: CGFloat]) {
|
||||
value.merge(nextValue()) { _, new in new }
|
||||
}
|
||||
}
|
||||
|
||||
private struct AttachTabOriginKey: PreferenceKey {
|
||||
static var defaultValue: [AttachmentTab: CGFloat] = [:]
|
||||
static func reduce(value: inout [AttachmentTab: CGFloat], nextValue: () -> [AttachmentTab: CGFloat]) {
|
||||
value.merge(nextValue()) { _, new in new }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - IdentifiableAsset
|
||||
|
||||
/// Wrapper to make PHAsset usable with SwiftUI `.fullScreenCover(item:)`.
|
||||
|
||||
@@ -181,7 +181,7 @@ struct ChatDetailView: View {
|
||||
}
|
||||
.overlay { chatEdgeGradients }
|
||||
// FPS overlay — uncomment for performance testing:
|
||||
// .overlay { FPSOverlayView() }
|
||||
.overlay { FPSOverlayView() }
|
||||
.overlay(alignment: .bottom) {
|
||||
if !route.isSystemAccount {
|
||||
KeyboardPaddedView {
|
||||
@@ -466,10 +466,11 @@ private extension ChatDetailView {
|
||||
color: .white
|
||||
)
|
||||
.frame(width: 11, height: 20)
|
||||
.allowsHitTesting(false)
|
||||
.frame(width: 36, height: 36)
|
||||
.contentShape(Circle())
|
||||
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
|
||||
}
|
||||
.frame(width: 36, height: 36)
|
||||
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Back")
|
||||
}
|
||||
|
||||
@@ -479,6 +480,7 @@ private extension ChatDetailView {
|
||||
.padding(.horizontal, 12)
|
||||
.frame(minWidth: 120)
|
||||
.frame(height: 44)
|
||||
.contentShape(Capsule())
|
||||
.background {
|
||||
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
|
||||
}
|
||||
@@ -490,6 +492,7 @@ private extension ChatDetailView {
|
||||
Button { openProfile() } label: {
|
||||
ChatDetailToolbarAvatar(route: route, size: 35)
|
||||
.frame(width: 36, height: 36)
|
||||
.contentShape(Circle())
|
||||
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -508,6 +511,7 @@ private extension ChatDetailView {
|
||||
.padding(.horizontal, 16)
|
||||
.frame(minWidth: 120)
|
||||
.frame(height: 44)
|
||||
.contentShape(Capsule())
|
||||
.background {
|
||||
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
|
||||
}
|
||||
@@ -519,6 +523,7 @@ private extension ChatDetailView {
|
||||
Button { openProfile() } label: {
|
||||
ChatDetailToolbarAvatar(route: route, size: 38)
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Circle())
|
||||
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -533,10 +538,10 @@ private extension ChatDetailView {
|
||||
color: .white
|
||||
)
|
||||
.frame(width: 11, height: 20)
|
||||
.allowsHitTesting(false)
|
||||
.frame(width: 36, height: 36)
|
||||
.frame(height: 44)
|
||||
.padding(.horizontal, 4)
|
||||
.contentShape(Capsule())
|
||||
.background {
|
||||
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
|
||||
}
|
||||
@@ -790,9 +795,8 @@ private extension ChatDetailView {
|
||||
scroll
|
||||
.scrollIndicators(.hidden)
|
||||
.overlay(alignment: .bottom) {
|
||||
KeyboardPaddedView(extraPadding: composerHeight + 4) {
|
||||
scrollToBottomButton(proxy: proxy)
|
||||
}
|
||||
scrollToBottomButton(proxy: proxy)
|
||||
.padding(.bottom, composerHeight + 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1766,6 +1770,13 @@ private extension ChatDetailView {
|
||||
func openProfile() {
|
||||
guard !route.isSavedMessages, !route.isSystemAccount else { return }
|
||||
isInputFocused = false
|
||||
// Force-dismiss keyboard at UIKit level immediately.
|
||||
// On iOS 26+, the async resignFirstResponder via syncFocus races with
|
||||
// the navigation transition — the system may re-focus the text view
|
||||
// when returning from the profile, causing a ghost keyboard.
|
||||
UIApplication.shared.sendAction(
|
||||
#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil
|
||||
)
|
||||
showOpponentProfile = true
|
||||
}
|
||||
|
||||
@@ -2109,17 +2120,31 @@ private extension ChatDetailView {
|
||||
func replyBar(for message: ChatMessage) -> some View {
|
||||
let senderName = senderDisplayName(for: message.fromPublicKey)
|
||||
let previewText: String = {
|
||||
let trimmed = message.text.trimmingCharacters(in: .whitespaces)
|
||||
if !trimmed.isEmpty { return message.text }
|
||||
if message.attachments.contains(where: { $0.type == .image }) { return "Photo" }
|
||||
// Attachment type labels — check BEFORE text so photo/avatar messages
|
||||
// always show their type even if text contains invisible characters.
|
||||
if message.attachments.contains(where: { $0.type == .image }) {
|
||||
let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return caption.isEmpty ? "Photo" : caption
|
||||
}
|
||||
if let file = message.attachments.first(where: { $0.type == .file }) {
|
||||
let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !caption.isEmpty { return caption }
|
||||
// Parse filename from preview (tag::fileSize::fileName)
|
||||
let parts = file.preview.components(separatedBy: "::")
|
||||
if parts.count >= 3 { return parts[2] }
|
||||
return file.id.isEmpty ? "File" : file.id
|
||||
}
|
||||
if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" }
|
||||
if message.attachments.contains(where: { $0.type == .messages }) { return "Forwarded message" }
|
||||
// No known attachment type — fall back to text
|
||||
let trimmed = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty { return message.text }
|
||||
if !message.attachments.isEmpty { return "Attachment" }
|
||||
return ""
|
||||
}()
|
||||
#if DEBUG
|
||||
let _ = print("📋 REPLY: preview='\(previewText.prefix(30))' text='\(message.text.prefix(30))' textHex=\(Array(message.text.utf8).prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")) atts=\(message.attachments.count) types=\(message.attachments.map { $0.type.rawValue })")
|
||||
#endif
|
||||
|
||||
HStack(spacing: 0) {
|
||||
RoundedRectangle(cornerRadius: 1.5)
|
||||
|
||||
@@ -37,24 +37,15 @@ struct FullScreenImageViewer: View {
|
||||
.opacity(backgroundOpacity)
|
||||
.ignoresSafeArea()
|
||||
|
||||
// Zoomable image
|
||||
// Zoomable image (visual only — no gestures here)
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.scaleEffect(scale)
|
||||
.offset(x: offset.width, y: offset.height + dismissOffset)
|
||||
.gesture(dragGesture)
|
||||
.gesture(pinchGesture)
|
||||
.onTapGesture(count: 2) {
|
||||
doubleTap()
|
||||
}
|
||||
.onTapGesture(count: 1) {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showControls.toggle()
|
||||
}
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
|
||||
// Close button
|
||||
// Close button (above gesture layer so it stays tappable)
|
||||
if showControls {
|
||||
VStack {
|
||||
HStack {
|
||||
@@ -77,6 +68,20 @@ struct FullScreenImageViewer: View {
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
// Gestures on the full-screen ZStack — not on the Image.
|
||||
// scaleEffect is visual-only and doesn't expand the Image's hit-test area,
|
||||
// so when zoomed to 2.5x, taps outside the original frame were lost.
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(count: 2) {
|
||||
doubleTap()
|
||||
}
|
||||
.onTapGesture(count: 1) {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showControls.toggle()
|
||||
}
|
||||
}
|
||||
.simultaneousGesture(pinchGesture)
|
||||
.simultaneousGesture(dragGesture)
|
||||
}
|
||||
|
||||
// MARK: - Background Opacity
|
||||
|
||||
@@ -134,7 +134,11 @@ struct ImageGalleryViewer: View {
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.disabled(currentZoomScale > 1.05 || isDismissing)
|
||||
// Block TabView page swipe when zoomed or dismissing,
|
||||
// but ONLY the scroll — NOT all user interaction.
|
||||
// .disabled() kills ALL gestures (taps, pinch, etc.) which prevents
|
||||
// double-tap zoom out. .scrollDisabled() only blocks the page swipe.
|
||||
.scrollDisabled(currentZoomScale > 1.05 || isDismissing)
|
||||
.opacity(presentationAlpha)
|
||||
|
||||
// Controls overlay
|
||||
|
||||
@@ -199,6 +199,17 @@ struct MessageAvatarView: View {
|
||||
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
|
||||
avatarImage = cached
|
||||
showAvatar = true // No animation for cached — show immediately
|
||||
return
|
||||
}
|
||||
// Outgoing avatar: sender is me — load from AvatarRepository (always available locally)
|
||||
if outgoing {
|
||||
let myKey = SessionManager.shared.currentPublicKey
|
||||
if let myAvatar = AvatarRepository.shared.loadAvatar(publicKey: myKey) {
|
||||
avatarImage = myAvatar
|
||||
showAvatar = true
|
||||
// Backfill AttachmentCache so next render hits fast path
|
||||
AttachmentCache.shared.saveImage(myAvatar, forAttachmentId: attachment.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,23 @@ struct ZoomableImagePage: View {
|
||||
.scaleEffect(effectiveScale)
|
||||
.offset(x: effectiveScale > 1.05 ? zoomOffset.width : 0,
|
||||
y: (effectiveScale > 1.05 ? zoomOffset.height : 0) + dismissDragOffset)
|
||||
// Expand hit-test area to full screen — scaleEffect is visual-only
|
||||
// and doesn't grow the Image's gesture frame. Without this,
|
||||
// double-tap to zoom out doesn't work on zoomed-in edges.
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
// Double tap: zoom to 2.5x or reset (MUST be before single tap)
|
||||
.onTapGesture(count: 2) {
|
||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
|
||||
if zoomScale > 1.1 {
|
||||
zoomScale = 1.0
|
||||
zoomOffset = .zero
|
||||
} else {
|
||||
zoomScale = 2.5
|
||||
}
|
||||
currentScale = zoomScale
|
||||
}
|
||||
}
|
||||
// Single tap: toggle controls / edge navigation
|
||||
.onTapGesture { location in
|
||||
let width = UIScreen.main.bounds.width
|
||||
@@ -47,20 +64,8 @@ struct ZoomableImagePage: View {
|
||||
showControls.toggle()
|
||||
}
|
||||
}
|
||||
// Double tap: zoom to 2.5x or reset
|
||||
.onTapGesture(count: 2) {
|
||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
|
||||
if zoomScale > 1.1 {
|
||||
zoomScale = 1.0
|
||||
zoomOffset = .zero
|
||||
} else {
|
||||
zoomScale = 2.5
|
||||
}
|
||||
currentScale = zoomScale
|
||||
}
|
||||
}
|
||||
// Pinch zoom
|
||||
.gesture(
|
||||
.simultaneousGesture(
|
||||
MagnifyGesture()
|
||||
.updating($pinchScale) { value, state, _ in
|
||||
state = value.magnification
|
||||
@@ -78,7 +83,7 @@ struct ZoomableImagePage: View {
|
||||
}
|
||||
)
|
||||
// Pan when zoomed
|
||||
.gesture(
|
||||
.simultaneousGesture(
|
||||
zoomScale > 1.05 ?
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
@@ -96,7 +101,6 @@ struct ZoomableImagePage: View {
|
||||
.simultaneousGesture(
|
||||
zoomScale <= 1.05 ? dismissDragGesture : nil
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
|
||||
@@ -106,6 +106,21 @@ struct ChatListView: View {
|
||||
guard let route = notification.object as? ChatRoute else { return }
|
||||
// Navigate to the chat from push notification tap
|
||||
navigationState.path = [route]
|
||||
// Clear pending route — consumed by onReceive (fast path)
|
||||
AppDelegate.pendingChatRoute = 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]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user