Доставка сообщений при потере сети, кэш фото при отправке, FPS клавиатуры, свайп фото, badge tab bar, release notes, sync unread fix

This commit is contained in:
2026-03-20 16:51:57 +05:00
parent 44652e0d97
commit e75c6bac12
20 changed files with 427 additions and 323 deletions

View File

@@ -187,15 +187,8 @@ struct AttachmentPanelView: View {
}
/// Glass circle background matching GlassBackButton (ButtonStyles.swift lines 2234).
@ViewBuilder
private var closeButtonGlass: some View {
if #available(iOS 26, *) {
Circle()
.fill(Color.white.opacity(0.08))
.glassEffect(.regular, in: .circle)
} else {
TelegramGlassCircle()
}
TelegramGlassCircle()
}
/// Title text changes based on selected tab.
@@ -318,15 +311,8 @@ struct AttachmentPanelView: View {
}
/// Glass background matching ChatDetailView's composer (`.thinMaterial` + stroke + shadow).
@ViewBuilder
private var captionBarBackground: some View {
if #available(iOS 26, *) {
RoundedRectangle(cornerRadius: 21, style: .continuous)
.fill(.clear)
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: 21, style: .continuous))
} else {
TelegramGlassRoundedRect(cornerRadius: 21)
}
TelegramGlassRoundedRect(cornerRadius: 21)
}
// MARK: - Tab Bar (Figma: glass capsule, 3 tabs)
@@ -349,22 +335,8 @@ struct AttachmentPanelView: View {
}
/// Glass background matching RosettaTabBar (lines 136149).
@ViewBuilder
private var tabBarBackground: some View {
if #available(iOS 26, *) {
// iOS 26+ native liquid glass
Capsule()
.fill(.clear)
.glassEffect(.regular, in: .capsule)
} else {
// iOS < 26 matches RosettaTabBar: .regularMaterial + border
Capsule()
.fill(.regularMaterial)
.overlay(
Capsule()
.strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5)
)
}
TelegramGlassCapsule()
}
/// Individual tab button matching RosettaTabBar dimensions exactly.
@@ -402,16 +374,7 @@ struct AttachmentPanelView: View {
.padding(.vertical, 6)
.background {
if isSelected {
if #available(iOS 26, *) {
Capsule()
.fill(.clear)
.glassEffect(.regular, in: .capsule)
} else {
// Matches RosettaTabBar selection indicator: .thinMaterial
Capsule()
.fill(.thinMaterial)
.padding(.vertical, 2)
}
TelegramGlassCapsule()
}
}
}

View File

@@ -804,6 +804,7 @@ private extension ChatDetailView {
)
.frame(width: 14, height: 8)
.frame(width: 42, height: 42)
.contentShape(Circle())
.background {
glass(shape: .circle, strokeOpacity: 0.18)
}
@@ -1456,6 +1457,7 @@ private extension ChatDetailView {
)
.frame(width: 21, height: 24)
.frame(width: 42, height: 42)
.contentShape(Circle())
.background { glass(shape: .circle, strokeOpacity: 0.18) }
}
.accessibilityLabel("Attach")
@@ -1558,8 +1560,8 @@ private extension ChatDetailView {
}
.padding(.leading, 16)
.padding(.trailing, composerTrailingPadding)
.padding(.top, 4)
.padding(.bottom, 4)
.padding(.top, 6)
.padding(.bottom, 12)
.simultaneousGesture(composerDismissGesture)
.animation(composerAnimation, value: canSend)
.animation(composerAnimation, value: shouldShowSendButton)
@@ -1588,7 +1590,8 @@ private extension ChatDetailView {
// MARK: - Bubble Position (Figma: Single / Top / Mid / Bottom)
/// Determines bubble position within a group of consecutive same-sender plain-text messages.
/// Determines bubble position within a group of consecutive same-sender messages.
/// Telegram parity: photo messages group with text messages from the same sender.
func bubblePosition(for index: Int) -> BubblePosition {
let hasPrev: Bool = {
guard index > 0 else { return false }
@@ -1596,7 +1599,7 @@ private extension ChatDetailView {
let current = messages[index]
let sameSender = current.isFromMe(myPublicKey: currentPublicKey)
== prev.isFromMe(myPublicKey: currentPublicKey)
return sameSender && prev.attachments.isEmpty && current.attachments.isEmpty
return sameSender
}()
let hasNext: Bool = {
@@ -1605,7 +1608,7 @@ private extension ChatDetailView {
let current = messages[index]
let sameSender = current.isFromMe(myPublicKey: currentPublicKey)
== next.isFromMe(myPublicKey: currentPublicKey)
return sameSender && next.attachments.isEmpty && current.attachments.isEmpty
return sameSender
}()
switch (hasPrev, hasNext) {
@@ -1637,27 +1640,18 @@ private extension ChatDetailView {
strokeOpacity: Double = 0.18,
strokeColor: Color = .white
) -> some View {
if #available(iOS 26.0, *) {
switch shape {
case .capsule:
Capsule().fill(.clear).glassEffect(.regular, in: .capsule)
case .circle:
Circle().fill(.clear).glassEffect(.regular, in: .circle)
case let .rounded(radius):
RoundedRectangle(cornerRadius: radius, style: .continuous)
.fill(.clear)
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: radius, style: .continuous))
}
} else {
// iOS < 26: Telegram glass (CABackdropLayer + blur 2.0 + dark foreground)
switch shape {
case .capsule:
TelegramGlassCapsule()
case .circle:
TelegramGlassCircle()
case let .rounded(radius):
TelegramGlassRoundedRect(cornerRadius: radius)
}
// Use TelegramGlass* UIViewRepresentable for ALL iOS versions.
// TelegramGlassUIView already supports iOS 26 natively (UIGlassEffect)
// and has isUserInteractionEnabled = false, guaranteeing touches pass
// through to parent Buttons. SwiftUI .glassEffect() modifier creates
// UIKit containers that intercept taps even with .allowsHitTesting(false).
switch shape {
case .capsule:
TelegramGlassCapsule()
case .circle:
TelegramGlassCircle()
case let .rounded(radius):
TelegramGlassRoundedRect(cornerRadius: radius)
}
}

View File

@@ -158,15 +158,19 @@ struct ImageGalleryViewer: View {
@ViewBuilder
private var controlsOverlay: some View {
if showControls && !isDismissing {
VStack(spacing: 0) {
VStack(spacing: 0) {
// Android parity: slide + fade, 200ms, FastOutSlowInEasing, 24pt slide distance.
if showControls && !isDismissing {
topBar
Spacer()
bottomBar
.transition(.move(edge: .top).combined(with: .opacity))
}
Spacer()
if showControls && !isDismissing {
bottomBar
.transition(.move(edge: .bottom).combined(with: .opacity))
}
.transition(.opacity)
.animation(.easeInOut(duration: 0.2), value: showControls)
}
.animation(.easeOut(duration: 0.2), value: showControls)
}
// MARK: - Top Bar (Android: sender name + date, back arrow)

View File

@@ -17,19 +17,86 @@ struct ZoomableImagePage: View {
let onEdgeTap: ((Int) -> Void)?
@State private var image: UIImage?
/// Vertical drag offset for dismiss gesture (SwiftUI DragGesture).
@State private var dismissDragOffset: CGFloat = 0
@State private var zoomScale: CGFloat = 1.0
@State private var zoomOffset: CGSize = .zero
@GestureState private var pinchScale: CGFloat = 1.0
var body: some View {
Group {
if let image {
ZoomableImageUIViewRepresentable(
image: image,
onDismiss: onDismiss,
onDismissProgress: onDismissProgress,
onDismissCancel: onDismissCancel,
onToggleControls: { showControls.toggle() },
onScaleChanged: { scale in currentScale = scale },
onEdgeTap: onEdgeTap
)
let effectiveScale = zoomScale * pinchScale
Image(uiImage: image)
.resizable()
.scaledToFit()
.scaleEffect(effectiveScale)
.offset(x: effectiveScale > 1.05 ? zoomOffset.width : 0,
y: (effectiveScale > 1.05 ? zoomOffset.height : 0) + dismissDragOffset)
// Single tap: toggle controls / edge navigation
.onTapGesture { location in
let width = UIScreen.main.bounds.width
let edgeZone = width * 0.20
if location.x < edgeZone {
onEdgeTap?(-1)
} else if location.x > width - edgeZone {
onEdgeTap?(1)
} else {
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(
MagnifyGesture()
.updating($pinchScale) { value, state, _ in
state = value.magnification
}
.onEnded { value in
let newScale = zoomScale * value.magnification
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
zoomScale = min(max(newScale, 1.0), 5.0)
if zoomScale <= 1.05 {
zoomScale = 1.0
zoomOffset = .zero
}
currentScale = zoomScale
}
}
)
// Pan when zoomed
.gesture(
zoomScale > 1.05 ?
DragGesture()
.onChanged { value in
zoomOffset = value.translation
}
.onEnded { _ in
// Clamp offset
}
: nil
)
// Dismiss drag (vertical swipe when not zoomed)
// simultaneousGesture so it coexists with TabView's page swipe.
// The 2.0× vertical ratio in dismissDragGesture prevents
// horizontal swipes from triggering dismiss.
.simultaneousGesture(
zoomScale <= 1.05 ? dismissDragGesture : nil
)
.contentShape(Rectangle())
} else {
placeholder
}
@@ -39,6 +106,35 @@ struct ZoomableImagePage: View {
}
}
/// Vertical drag-to-dismiss gesture.
/// Uses minimumDistance:40 to give TabView's page swipe a head start.
private var dismissDragGesture: some Gesture {
DragGesture(minimumDistance: 40, coordinateSpace: .local)
.onChanged { value in
let dy = abs(value.translation.height)
let dx = abs(value.translation.width)
// Only vertical-dominant drags trigger dismiss
guard dy > dx * 2.0 else { return }
dismissDragOffset = value.translation.height
let progress = min(abs(dismissDragOffset) / 300, 1.0)
onDismissProgress(progress)
}
.onEnded { value in
let velocityY = abs(value.predictedEndTranslation.height - value.translation.height)
if abs(dismissDragOffset) > 100 || velocityY > 500 {
// Dismiss keep offset so photo doesn't jump back before fade-out
onDismiss()
} else {
// Snap back
withAnimation(.easeOut(duration: 0.25)) {
dismissDragOffset = 0
}
onDismissCancel()
}
}
}
// MARK: - Placeholder
private var placeholder: some View {
@@ -116,6 +212,7 @@ final class ImageGestureContainerView: UIView, UIGestureRecognizerDelegate {
// MARK: - Subviews
private let imageView = UIImageView()
private var panGesture: UIPanGestureRecognizer?
// MARK: - Transform State
@@ -184,10 +281,10 @@ final class ImageGestureContainerView: UIView, UIGestureRecognizerDelegate {
pinch.delegate = self
addGestureRecognizer(pinch)
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
pan.delegate = self
pan.maximumNumberOfTouches = 1
addGestureRecognizer(pan)
// Pan gesture REMOVED replaced by SwiftUI DragGesture on the wrapper view.
// UIKit UIPanGestureRecognizer on UIViewRepresentable intercepts ALL touches
// before SwiftUI TabView gets them, preventing page swipe navigation.
// SwiftUI DragGesture cooperates with TabView natively.
let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap))
doubleTap.numberOfTapsRequired = 2
@@ -276,62 +373,32 @@ final class ImageGestureContainerView: UIView, UIGestureRecognizerDelegate {
}
}
// MARK: - Pan Gesture (Pan when zoomed, Dismiss when not)
// MARK: - Pan Gesture (Pan when zoomed ONLY)
// Dismiss gesture moved to SwiftUI DragGesture on ZoomableImagePage wrapper
// to allow TabView page swipe to work.
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
// Only handle pan when zoomed dismiss is handled by SwiftUI DragGesture
guard currentScale > 1.05 else {
gesture.state = .cancelled
return
}
let translation = gesture.translation(in: self)
let velocity = gesture.velocity(in: self)
switch gesture.state {
case .began:
panStartOffset = currentOffset
gestureAxisLocked = false
isDismissGesture = false
case .changed:
if currentScale > 1.05 {
// Zoomed: pan the image
currentOffset = CGPoint(
x: panStartOffset.x + translation.x,
y: panStartOffset.y + translation.y
)
applyTransform()
} else {
// Not zoomed: detect axis
if !gestureAxisLocked {
let dx = abs(translation.x)
let dy = abs(translation.y)
// Android: abs(panChange.y) > abs(panChange.x) * 1.5
if dx > touchSlop || dy > touchSlop {
gestureAxisLocked = true
isDismissGesture = dy > dx * 1.2
}
}
if isDismissGesture {
dismissOffset = translation.y
let progress = min(abs(dismissOffset) / 300, 1.0)
onDismissProgress?(progress)
applyTransform()
}
}
currentOffset = CGPoint(
x: panStartOffset.x + translation.x,
y: panStartOffset.y + translation.y
)
applyTransform()
case .ended, .cancelled:
if currentScale > 1.05 {
clampOffset(animated: true)
} else if isDismissGesture {
let velocityY = abs(velocity.y)
if abs(dismissOffset) > dismissDistanceThreshold || velocityY > dismissVelocityThreshold {
// Dismiss with fade-out (Android: smoothDismiss 200ms fade)
onDismiss?()
} else {
// Snap back
dismissOffset = 0
onDismissCancel?()
applyTransform(animated: true)
}
}
isDismissGesture = false
clampOffset(animated: true)
gestureAxisLocked = false
default: break
@@ -429,13 +496,9 @@ final class ImageGestureContainerView: UIView, UIGestureRecognizerDelegate {
}
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true }
let velocity = pan.velocity(in: self)
if currentScale <= 1.05 {
// Not zoomed: only begin for vertical-dominant drags.
// Let horizontal swipes pass through to TabView for paging.
return abs(velocity.y) > abs(velocity.x) * 1.2
if gestureRecognizer is UIPanGestureRecognizer {
// Pan only when zoomed dismiss handled by SwiftUI DragGesture
return currentScale > 1.05
}
return true
}

View File

@@ -377,6 +377,9 @@ private struct ToolbarTitleView: View {
.foregroundStyle(RosettaColors.Adaptive.text)
.contentTransition(.numericText())
.animation(.easeInOut(duration: 0.25), value: state)
.onTapGesture {
NotificationCenter.default.post(name: .chatListScrollToTop, object: nil)
}
} else {
ToolbarStatusLabel(title: "Connecting...")
}
@@ -589,7 +592,10 @@ private struct ChatListDialogContent: View {
// MARK: - Dialog List
private static let topAnchorId = "chatlist_top"
private func dialogList(pinned: [Dialog], unpinned: [Dialog], requestsCount: Int) -> some View {
ScrollViewReader { scrollProxy in
List {
if viewModel.isLoading {
ForEach(0..<8, id: \.self) { _ in
@@ -630,6 +636,17 @@ private struct ChatListDialogContent: View {
.scrollContentBackground(.hidden)
.scrollDismissesKeyboard(.immediately)
.scrollIndicators(.hidden)
// Scroll-to-top: tap "Chats" in toolbar
.onReceive(NotificationCenter.default.publisher(for: .chatListScrollToTop)) { _ in
// Scroll to first dialog ID (pinned or unpinned)
let firstId = pinned.first?.id ?? unpinned.first?.id
if let firstId {
withAnimation(.easeOut(duration: 0.3)) {
scrollProxy.scrollTo(firstId, anchor: .top)
}
}
}
} // ScrollViewReader
}
private func chatRow(_ dialog: Dialog, isFirst: Bool = false) -> some View {