Доставка сообщений при потере сети, кэш фото при отправке, FPS клавиатуры, свайп фото, badge tab bar, release notes, sync unread fix
This commit is contained in:
@@ -187,15 +187,8 @@ struct AttachmentPanelView: View {
|
||||
}
|
||||
|
||||
/// Glass circle background matching GlassBackButton (ButtonStyles.swift lines 22–34).
|
||||
@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 136–149).
|
||||
@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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user