Уведомления CarPlay, панель вложений с Lottie, фикс reply preview, плавная анимация клавиатуры, стабильность WebSocket

This commit is contained in:
2026-03-22 01:58:13 +05:00
parent 65e5991f97
commit 9289bb2efd
20 changed files with 645 additions and 172 deletions

View File

@@ -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 136149).
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:)`.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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