Фикс: бэкграунд звонки — аудио, имя на CallKit, deactivation order, UUID race
This commit is contained in:
@@ -276,6 +276,7 @@ struct ChatDetailView: View {
|
||||
// Telegram-like read policy: mark read only when dialog is truly readable
|
||||
// (view active + list at bottom).
|
||||
markDialogAsRead()
|
||||
DialogRepository.shared.setMention(opponentKey: route.publicKey, hasMention: false)
|
||||
// Request user info (non-mutating, won't trigger list rebuild)
|
||||
requestUserInfoIfNeeded()
|
||||
// Delay DialogRepository mutations to let navigation transition complete.
|
||||
@@ -819,12 +820,9 @@ private extension ChatDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached tiled pattern color — computed once, reused across renders
|
||||
/// Default chat wallpaper — full-screen scaled image.
|
||||
/// Chat wallpaper — reads user selection from @AppStorage.
|
||||
private var tiledChatBackground: some View {
|
||||
Image("ChatWallpaper")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
WallpaperView()
|
||||
}
|
||||
|
||||
// MARK: - Messages
|
||||
|
||||
@@ -27,8 +27,33 @@ final class CoreTextTextLayout {
|
||||
let width: CGFloat // Typographic advance width (CTLineGetTypographicBounds)
|
||||
let ascent: CGFloat // Distance from baseline to top of tallest glyph
|
||||
let descent: CGFloat // Distance from baseline to bottom of lowest glyph
|
||||
let stringRange: NSRange // Character range this line covers in the original string
|
||||
}
|
||||
|
||||
// MARK: - Link Detection
|
||||
|
||||
/// A detected URL with bounding rects for hit testing.
|
||||
struct LinkInfo {
|
||||
let url: URL
|
||||
let range: NSRange
|
||||
var rects: [CGRect]
|
||||
}
|
||||
|
||||
/// TLD whitelist — desktop parity (desktop/app/constants.ts lines 38-63).
|
||||
static let allowedTLDs: Set<String> = [
|
||||
"com", "ru", "ua", "org", "net", "edu", "gov", "io", "tech", "info",
|
||||
"biz", "me", "online", "site", "app", "dev", "chat", "gg", "fm", "tv",
|
||||
"im", "sc", "su", "by"
|
||||
]
|
||||
|
||||
/// Cached data detector (regex compilation is expensive).
|
||||
private static let linkDetector: NSDataDetector? = {
|
||||
try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||
}()
|
||||
|
||||
/// Link color matching Telegram accent blue (#3390EC).
|
||||
static let linkColor = UIColor(red: 0x33/255, green: 0x90/255, blue: 0xEC/255, alpha: 1)
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
let lines: [Line]
|
||||
@@ -37,6 +62,7 @@ final class CoreTextTextLayout {
|
||||
let lastLineHasRTL: Bool
|
||||
let lastLineHasBlockQuote: Bool
|
||||
let textColor: UIColor
|
||||
let links: [LinkInfo]
|
||||
|
||||
private init(
|
||||
lines: [Line],
|
||||
@@ -44,7 +70,8 @@ final class CoreTextTextLayout {
|
||||
lastLineWidth: CGFloat,
|
||||
lastLineHasRTL: Bool,
|
||||
lastLineHasBlockQuote: Bool,
|
||||
textColor: UIColor
|
||||
textColor: UIColor,
|
||||
links: [LinkInfo] = []
|
||||
) {
|
||||
self.lines = lines
|
||||
self.size = size
|
||||
@@ -52,6 +79,19 @@ final class CoreTextTextLayout {
|
||||
self.lastLineHasRTL = lastLineHasRTL
|
||||
self.lastLineHasBlockQuote = lastLineHasBlockQuote
|
||||
self.textColor = textColor
|
||||
self.links = links
|
||||
}
|
||||
|
||||
/// Returns the URL at the given point, or nil if no link at that position.
|
||||
func linkAt(point: CGPoint) -> URL? {
|
||||
for link in links {
|
||||
for rect in link.rects {
|
||||
if rect.insetBy(dx: -4, dy: -4).contains(point) {
|
||||
return link.url
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Telegram Line Spacing
|
||||
@@ -102,9 +142,26 @@ final class CoreTextTextLayout {
|
||||
.font: font,
|
||||
.foregroundColor: textColor
|
||||
]
|
||||
let attrString = NSAttributedString(string: text, attributes: attributes)
|
||||
let attrString = NSMutableAttributedString(string: text, attributes: attributes)
|
||||
let stringLength = attrString.length
|
||||
|
||||
// ── Link detection (desktop parity: TextParser.tsx + constants.ts TLD whitelist) ──
|
||||
var detectedLinks: [(url: URL, range: NSRange)] = []
|
||||
if let detector = linkDetector {
|
||||
let fullRange = NSRange(location: 0, length: stringLength)
|
||||
detector.enumerateMatches(in: text, options: [], range: fullRange) { result, _, _ in
|
||||
guard let result, let url = result.url else { return }
|
||||
let host = url.host?.lowercased() ?? ""
|
||||
let tld = host.split(separator: ".").last.map(String.init) ?? ""
|
||||
guard allowedTLDs.contains(tld) else { return }
|
||||
attrString.addAttributes([
|
||||
.foregroundColor: linkColor,
|
||||
.underlineStyle: NSUnderlineStyle.single.rawValue
|
||||
], range: result.range)
|
||||
detectedLinks.append((url: url, range: result.range))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Typesetter (Telegram: InteractiveTextComponent line 1481) ──
|
||||
let typesetter = CTTypesetterCreateWithAttributedString(attrString as CFAttributedString)
|
||||
|
||||
@@ -163,7 +220,8 @@ final class CoreTextTextLayout {
|
||||
origin: CGPoint(x: 0, y: currentY),
|
||||
width: clampedWidth,
|
||||
ascent: lineAscent,
|
||||
descent: lineDescent
|
||||
descent: lineDescent,
|
||||
stringRange: NSRange(location: currentIndex, length: lineCharCount)
|
||||
))
|
||||
|
||||
// Advance by font line height (Telegram uses font-level, not per-line)
|
||||
@@ -183,13 +241,41 @@ final class CoreTextTextLayout {
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.hasPrefix(">")
|
||||
|
||||
// ── Compute link bounding rects ──
|
||||
var linkInfos: [LinkInfo] = []
|
||||
for detected in detectedLinks {
|
||||
var rects: [CGRect] = []
|
||||
for line in resultLines {
|
||||
let overlap = NSIntersectionRange(line.stringRange, detected.range)
|
||||
guard overlap.length > 0 else { continue }
|
||||
let lineStartInLink = overlap.location - line.stringRange.location
|
||||
let lineEndInLink = lineStartInLink + overlap.length
|
||||
var xStart: CGFloat = 0
|
||||
var xEnd: CGFloat = 0
|
||||
// CTLineGetOffsetForStringIndex uses UTF-16 offsets relative to line start
|
||||
xStart = CGFloat(CTLineGetOffsetForStringIndex(
|
||||
line.ctLine, overlap.location, nil
|
||||
))
|
||||
xEnd = CGFloat(CTLineGetOffsetForStringIndex(
|
||||
line.ctLine, overlap.location + overlap.length, nil
|
||||
))
|
||||
if xEnd < xStart { swap(&xStart, &xEnd) }
|
||||
let lineH = line.ascent + line.descent
|
||||
rects.append(CGRect(x: xStart, y: line.origin.y, width: xEnd - xStart, height: lineH))
|
||||
}
|
||||
if !rects.isEmpty {
|
||||
linkInfos.append(LinkInfo(url: detected.url, range: detected.range, rects: rects))
|
||||
}
|
||||
}
|
||||
|
||||
return CoreTextTextLayout(
|
||||
lines: resultLines,
|
||||
size: CGSize(width: ceil(maxLineWidth), height: ceil(currentY)),
|
||||
lastLineWidth: ceil(lastLineWidth),
|
||||
lastLineHasRTL: lastLineHasRTL,
|
||||
lastLineHasBlockQuote: lastLineHasBlockQuote,
|
||||
textColor: textColor
|
||||
textColor: textColor,
|
||||
links: linkInfos
|
||||
)
|
||||
}
|
||||
|
||||
@@ -254,15 +340,29 @@ final class CoreTextLabel: UIView {
|
||||
// + line.ascent = baseline (distance from top to baseline)
|
||||
// Telegram: context.textPosition = CGPoint(x: minX, y: maxY - descent)
|
||||
// which equals origin.y + ascent (since maxY = origin.y + ascent + descent)
|
||||
context.textPosition = CGPoint(
|
||||
x: line.origin.x,
|
||||
y: line.origin.y + line.ascent
|
||||
)
|
||||
let baselineY = line.origin.y + line.ascent
|
||||
context.textPosition = CGPoint(x: line.origin.x, y: baselineY)
|
||||
|
||||
// Draw each glyph run (Telegram: CTRunDraw per run)
|
||||
let glyphRuns = CTLineGetGlyphRuns(line.ctLine) as! [CTRun]
|
||||
for run in glyphRuns {
|
||||
CTRunDraw(run, context, CFRangeMake(0, 0)) // 0,0 = all glyphs
|
||||
CTRunDraw(run, context, CFRangeMake(0, 0))
|
||||
|
||||
// Draw underline for link runs (CTRunDraw doesn't render underlines)
|
||||
let attrs = CTRunGetAttributes(run) as? [NSAttributedString.Key: Any] ?? [:]
|
||||
if let underline = attrs[.underlineStyle] as? Int, underline != 0,
|
||||
let color = attrs[.foregroundColor] as? UIColor {
|
||||
var runAscent: CGFloat = 0
|
||||
let runWidth = CGFloat(CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &runAscent, nil, nil))
|
||||
var origin = CGPoint.zero
|
||||
CTRunGetPositions(run, CFRangeMake(0, 1), &origin)
|
||||
let underlineY = baselineY + 2
|
||||
context.setStrokeColor(color.cgColor)
|
||||
context.setLineWidth(0.5)
|
||||
context.move(to: CGPoint(x: line.origin.x + origin.x, y: underlineY))
|
||||
context.addLine(to: CGPoint(x: line.origin.x + origin.x + runWidth, y: underlineY))
|
||||
context.strokePath()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Combine
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import Photos
|
||||
@@ -22,36 +23,103 @@ struct ImageViewerState: Equatable {
|
||||
let sourceFrame: CGRect
|
||||
}
|
||||
|
||||
// MARK: - GalleryDismissPanCoordinator
|
||||
|
||||
/// Manages the vertical pan gesture for gallery dismiss.
|
||||
/// Attached to the hosting controller's view (NOT as a SwiftUI overlay) so it
|
||||
/// doesn't block SwiftUI gestures (pinch zoom, taps) on the content below.
|
||||
/// Previous approach: `HeroPanGestureOverlay` (UIViewRepresentable overlay) blocked
|
||||
/// all SwiftUI gestures at the hit-test level — pinch zoom and double-tap never worked.
|
||||
final class GalleryDismissPanCoordinator: NSObject, ObservableObject, UIGestureRecognizerDelegate {
|
||||
/// Current vertical drag offset during dismiss gesture.
|
||||
@Published var dragOffset: CGSize = .zero
|
||||
/// Toggles on every pan-end event — use `.onChange` to react.
|
||||
@Published private(set) var panEndSignal: Bool = false
|
||||
/// Y velocity at the moment the pan gesture ended (pt/s).
|
||||
private(set) var endVelocityY: CGFloat = 0
|
||||
/// Set to false to disable the dismiss gesture (e.g. when zoomed in).
|
||||
var isEnabled: Bool = true
|
||||
|
||||
@objc func handlePan(_ gesture: UIPanGestureRecognizer) {
|
||||
guard isEnabled else {
|
||||
if gesture.state == .began { gesture.state = .cancelled }
|
||||
return
|
||||
}
|
||||
let translation = gesture.translation(in: gesture.view)
|
||||
switch gesture.state {
|
||||
case .began, .changed:
|
||||
// Vertical only — Telegram parity (no diagonal drag).
|
||||
dragOffset = CGSize(width: 0, height: translation.y)
|
||||
case .ended, .cancelled:
|
||||
endVelocityY = gesture.velocity(in: gesture.view).y
|
||||
panEndSignal.toggle()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Only begin for downward vertical drags.
|
||||
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
guard isEnabled else { return false }
|
||||
guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return false }
|
||||
let v = pan.velocity(in: pan.view)
|
||||
return v.y > abs(v.x)
|
||||
}
|
||||
|
||||
// Allow simultaneous recognition with non-pan gestures (pinch, taps).
|
||||
func gestureRecognizer(
|
||||
_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer
|
||||
) -> Bool {
|
||||
!(other is UIPanGestureRecognizer)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ImageViewerPresenter
|
||||
|
||||
/// UIHostingController subclass that hides the status bar.
|
||||
/// Uses `AnyView` instead of generic `Content` to avoid a Swift compiler crash
|
||||
/// in the SIL inliner (SR-XXXXX / rdar://XXXXX).
|
||||
private final class StatusBarHiddenHostingController: UIHostingController<AnyView> {
|
||||
override var prefersStatusBarHidden: Bool { true }
|
||||
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation { .fade }
|
||||
}
|
||||
|
||||
/// Presents the image gallery viewer using UIKit `overFullScreen` presentation
|
||||
/// — no bottom-sheet slide-up. Appears instantly; the viewer itself fades in.
|
||||
/// Presents the image gallery viewer using UIKit `overFullScreen` presentation.
|
||||
/// Telegram parity: the viewer appears as a fade overlay covering nav bar and tab bar.
|
||||
@MainActor
|
||||
final class ImageViewerPresenter {
|
||||
|
||||
static let shared = ImageViewerPresenter()
|
||||
private weak var presentedController: UIViewController?
|
||||
private var panCoordinator: GalleryDismissPanCoordinator?
|
||||
|
||||
func present(state: ImageViewerState) {
|
||||
guard presentedController == nil else { return }
|
||||
|
||||
let viewer = ImageGalleryViewer(state: state, onDismiss: { [weak self] in
|
||||
self?.dismiss()
|
||||
})
|
||||
let coordinator = GalleryDismissPanCoordinator()
|
||||
panCoordinator = coordinator
|
||||
|
||||
let viewer = ImageGalleryViewer(
|
||||
state: state,
|
||||
panCoordinator: coordinator,
|
||||
onDismiss: { [weak self] in self?.dismiss() }
|
||||
)
|
||||
|
||||
let hostingController = StatusBarHiddenHostingController(rootView: AnyView(viewer))
|
||||
hostingController.modalPresentationStyle = .overFullScreen
|
||||
hostingController.view.backgroundColor = .clear
|
||||
|
||||
// Pan gesture on hosting controller's view — NOT a SwiftUI overlay.
|
||||
// UIKit gesture recognizers on a hosting view coexist with SwiftUI gestures
|
||||
// on child views (pinch, taps, TabView swipe) without blocking them.
|
||||
let pan = UIPanGestureRecognizer(
|
||||
target: coordinator,
|
||||
action: #selector(GalleryDismissPanCoordinator.handlePan)
|
||||
)
|
||||
pan.minimumNumberOfTouches = 1
|
||||
pan.maximumNumberOfTouches = 1
|
||||
pan.delegate = coordinator
|
||||
hostingController.view.addGestureRecognizer(pan)
|
||||
|
||||
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let root = windowScene.keyWindow?.rootViewController
|
||||
else { return }
|
||||
@@ -65,6 +133,7 @@ final class ImageViewerPresenter {
|
||||
}
|
||||
|
||||
func dismiss() {
|
||||
panCoordinator = nil
|
||||
presentedController?.dismiss(animated: false)
|
||||
presentedController = nil
|
||||
}
|
||||
@@ -73,11 +142,12 @@ final class ImageViewerPresenter {
|
||||
// MARK: - ImageGalleryViewer
|
||||
|
||||
/// Multi-photo gallery viewer with hero transition animation.
|
||||
/// Adapted 1:1 from PhotosTransition/Helpers/PhotoGridView.swift — DetailPhotosView.
|
||||
/// Hero positioning is per-page INSIDE ForEach (not on TabView).
|
||||
/// Telegram parity: hero open/close, vertical-only interactive dismiss,
|
||||
/// slide-in panels, counter below name capsule, pinch zoom, double-tap zoom.
|
||||
struct ImageGalleryViewer: View {
|
||||
|
||||
let state: ImageViewerState
|
||||
@ObservedObject var panCoordinator: GalleryDismissPanCoordinator
|
||||
let onDismiss: () -> Void
|
||||
|
||||
@State private var currentPage: Int
|
||||
@@ -85,17 +155,27 @@ struct ImageGalleryViewer: View {
|
||||
@State private var currentZoomScale: CGFloat = 1.0
|
||||
@State private var isDismissing = false
|
||||
@State private var isExpanded: Bool = false
|
||||
@State private var dragOffset: CGSize = .zero
|
||||
@State private var viewSize: CGSize = UIScreen.main.bounds.size
|
||||
|
||||
private static let dateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "d MMMM, HH:mm"
|
||||
return formatter
|
||||
let f = DateFormatter()
|
||||
f.dateStyle = .none
|
||||
f.timeStyle = .short
|
||||
f.doesRelativeDateFormatting = true
|
||||
return f
|
||||
}()
|
||||
|
||||
init(state: ImageViewerState, onDismiss: @escaping () -> Void) {
|
||||
private static let relativeDateFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateStyle = .medium
|
||||
f.timeStyle = .none
|
||||
f.doesRelativeDateFormatting = true
|
||||
return f
|
||||
}()
|
||||
|
||||
init(state: ImageViewerState, panCoordinator: GalleryDismissPanCoordinator, onDismiss: @escaping () -> Void) {
|
||||
self.state = state
|
||||
self.panCoordinator = panCoordinator
|
||||
self.onDismiss = onDismiss
|
||||
self._currentPage = State(initialValue: state.initialIndex)
|
||||
}
|
||||
@@ -108,64 +188,76 @@ struct ImageGalleryViewer: View {
|
||||
.interpolatingSpring(duration: 0.3, bounce: 0, initialVelocity: 0)
|
||||
}
|
||||
|
||||
private var interactiveOpacity: CGFloat {
|
||||
let opacityY = abs(dragOffset.height) / (viewSize.height * 0.3)
|
||||
return isExpanded ? max(1 - opacityY, 0) : 0
|
||||
/// Background opacity: fades over 80pt drag (Telegram: `abs(distance) / 80`).
|
||||
private var backgroundOpacity: CGFloat {
|
||||
let progress = min(abs(panCoordinator.dragOffset.height) / 80, 1)
|
||||
return isExpanded ? max(1 - progress, 0) : 0
|
||||
}
|
||||
|
||||
/// Overlay/toolbar opacity: fades over 50pt drag (Telegram: `abs(distance) / 50`).
|
||||
private var overlayDragOpacity: CGFloat {
|
||||
1 - min(abs(panCoordinator.dragOffset.height) / 50, 1)
|
||||
}
|
||||
|
||||
private func formattedDate(_ date: Date) -> String {
|
||||
let dayPart = Self.relativeDateFormatter.string(from: date)
|
||||
let timePart = Self.dateFormatter.string(from: date)
|
||||
return "\(dayPart) at \(timePart)"
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
let sourceFrame = state.sourceFrame
|
||||
|
||||
// Hero positioning per-page inside ForEach — matches reference exactly
|
||||
TabView(selection: $currentPage) {
|
||||
ForEach(Array(state.images.enumerated()), id: \.element.attachmentId) { index, info in
|
||||
ZoomableImagePage(
|
||||
attachmentId: info.attachmentId,
|
||||
onDismiss: { dismissAction() },
|
||||
showControls: $showControls,
|
||||
currentScale: $currentZoomScale,
|
||||
onEdgeTap: { direction in navigateEdgeTap(direction: direction) }
|
||||
)
|
||||
.frame(
|
||||
width: isExpanded ? viewSize.width : sourceFrame.width,
|
||||
height: isExpanded ? viewSize.height : sourceFrame.height
|
||||
)
|
||||
.clipped()
|
||||
.offset(
|
||||
x: isExpanded ? 0 : sourceFrame.minX,
|
||||
y: isExpanded ? 0 : sourceFrame.minY
|
||||
)
|
||||
.offset(dragOffset)
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
maxHeight: .infinity,
|
||||
alignment: isExpanded ? .center : .topLeading
|
||||
)
|
||||
.tag(index)
|
||||
.ignoresSafeArea()
|
||||
GeometryReader { geometry in
|
||||
let size = geometry.size
|
||||
|
||||
TabView(selection: $currentPage) {
|
||||
ForEach(Array(state.images.enumerated()), id: \.element.attachmentId) { index, info in
|
||||
// Hero frame/offset only on the initial page — other pages are
|
||||
// always fullscreen. Prevents glitch where lazily-created pages
|
||||
// briefly render at sourceFrame size during TabView swipe.
|
||||
let isHeroPage = index == state.initialIndex
|
||||
let heroActive = isHeroPage && !isExpanded
|
||||
|
||||
ZoomableImagePage(
|
||||
attachmentId: info.attachmentId,
|
||||
onDismiss: { dismissAction() },
|
||||
showControls: $showControls,
|
||||
currentScale: $currentZoomScale,
|
||||
onEdgeTap: { direction in navigateEdgeTap(direction: direction) }
|
||||
)
|
||||
.frame(
|
||||
width: heroActive ? sourceFrame.width : size.width,
|
||||
height: heroActive ? sourceFrame.height : size.height
|
||||
)
|
||||
.clipped()
|
||||
.offset(
|
||||
x: heroActive ? sourceFrame.minX : 0,
|
||||
y: heroActive ? sourceFrame.minY : 0
|
||||
)
|
||||
.offset(panCoordinator.dragOffset)
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
maxHeight: .infinity,
|
||||
alignment: heroActive ? .topLeading : .center
|
||||
)
|
||||
.tag(index)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.scrollDisabled(currentZoomScale > 1.05 || isDismissing)
|
||||
.contentShape(Rectangle())
|
||||
.overlay { galleryOverlay }
|
||||
.background {
|
||||
Color.black
|
||||
.opacity(backgroundOpacity)
|
||||
}
|
||||
.allowsHitTesting(isExpanded)
|
||||
.onAppear { viewSize = size }
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.ignoresSafeArea()
|
||||
.scrollDisabled(currentZoomScale > 1.05 || isDismissing)
|
||||
.contentShape(Rectangle())
|
||||
.overlay {
|
||||
// Pan gesture overlay — UIKit gesture for iOS 17+ compat
|
||||
HeroPanGestureOverlay { gesture in
|
||||
handlePanGesture(gesture)
|
||||
}
|
||||
.allowsHitTesting(isExpanded && currentZoomScale <= 1.05)
|
||||
}
|
||||
.overlay {
|
||||
overlayActions
|
||||
}
|
||||
.background {
|
||||
Color.black
|
||||
.opacity(interactiveOpacity)
|
||||
.opacity(isExpanded ? 1 : 0)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
.allowsHitTesting(isExpanded)
|
||||
.statusBarHidden(true)
|
||||
.task {
|
||||
prefetchAdjacentImages(around: state.initialIndex)
|
||||
@@ -177,96 +269,133 @@ struct ImageGalleryViewer: View {
|
||||
.onChange(of: currentPage) { _, newPage in
|
||||
prefetchAdjacentImages(around: newPage)
|
||||
}
|
||||
.onChange(of: currentZoomScale) { _, newScale in
|
||||
panCoordinator.isEnabled = newScale <= 1.05
|
||||
}
|
||||
.onChange(of: panCoordinator.panEndSignal) { _, _ in
|
||||
handlePanEnd()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pan Gesture
|
||||
// MARK: - Pan End Handler
|
||||
|
||||
private func handlePanGesture(_ gesture: UIPanGestureRecognizer) {
|
||||
let panState = gesture.state
|
||||
let translation = gesture.translation(in: gesture.view)
|
||||
|
||||
if panState == .began || panState == .changed {
|
||||
dragOffset = .init(width: translation.x, height: translation.y)
|
||||
private func handlePanEnd() {
|
||||
let offsetY = panCoordinator.dragOffset.height
|
||||
let velocityY = panCoordinator.endVelocityY
|
||||
// Telegram parity: dismiss on 50pt drag OR fast downward flick (>1000 pt/s).
|
||||
if offsetY > 50 || velocityY > 1000 {
|
||||
dismissAction()
|
||||
} else {
|
||||
if dragOffset.height > 50 {
|
||||
heroDismiss()
|
||||
} else {
|
||||
withAnimation(heroAnimation.speed(1.2)) {
|
||||
dragOffset = .zero
|
||||
}
|
||||
withAnimation(heroAnimation.speed(1.2)) {
|
||||
panCoordinator.dragOffset = .zero
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Overlay Actions (matches PhotosTransition/ContentView.swift OverlayActionView)
|
||||
// MARK: - Gallery Overlay (Telegram parity)
|
||||
|
||||
@ViewBuilder
|
||||
private var overlayActions: some View {
|
||||
let overlayOpacity: CGFloat = 1 - min(abs(dragOffset.height / 30), 1)
|
||||
|
||||
if showControls && !isDismissing && isExpanded {
|
||||
VStack {
|
||||
// Top actions
|
||||
HStack {
|
||||
glassButton(systemName: "chevron.left") { dismissAction() }
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
private var galleryOverlay: some View {
|
||||
if !isDismissing && isExpanded {
|
||||
ZStack {
|
||||
// Top panel — slides DOWN from above on show, UP on hide
|
||||
VStack(spacing: 4) {
|
||||
topPanel
|
||||
if state.images.count > 1 {
|
||||
glassLabel("\(currentPage + 1) / \(state.images.count)")
|
||||
counterBadge
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.overlay {
|
||||
if let info = currentInfo {
|
||||
glassLabel(info.senderName)
|
||||
.contentTransition(.numericText())
|
||||
.animation(.easeInOut, value: currentPage)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
// Bottom actions
|
||||
HStack {
|
||||
glassButton(systemName: "square.and.arrow.up.fill") { shareCurrentImage() }
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
glassButton(systemName: "square.and.arrow.down") { saveCurrentImage() }
|
||||
.frame(maxWidth: .infinity)
|
||||
.offset(y: showControls ? 0 : -120)
|
||||
.allowsHitTesting(showControls)
|
||||
|
||||
// Bottom panel — slides UP from below on show, DOWN on hide
|
||||
VStack {
|
||||
Spacer()
|
||||
bottomPanel
|
||||
}
|
||||
.offset(y: showControls ? 0 : 120)
|
||||
.allowsHitTesting(showControls)
|
||||
}
|
||||
.padding(.horizontal, 15)
|
||||
.compositingGroup()
|
||||
.opacity(overlayOpacity)
|
||||
.opacity(overlayDragOpacity)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: showControls)
|
||||
.environment(\.colorScheme, .dark)
|
||||
.transition(.opacity)
|
||||
.animation(.easeOut(duration: 0.2), value: showControls)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Glass Button / Label helpers
|
||||
// MARK: - Top Panel
|
||||
|
||||
private func glassButton(systemName: String, action: @escaping () -> Void) -> some View {
|
||||
private var topPanel: some View {
|
||||
HStack {
|
||||
glassCircleButton(systemName: "chevron.left") { dismissAction() }
|
||||
Spacer(minLength: 0)
|
||||
glassCircleButton(systemName: "ellipsis") { }
|
||||
}
|
||||
.overlay {
|
||||
if let info = currentInfo {
|
||||
VStack(spacing: 2) {
|
||||
Text(info.senderName)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
Text(formattedDate(info.timestamp))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background { TelegramGlassCapsule() }
|
||||
.contentTransition(.numericText())
|
||||
.animation(.easeInOut, value: currentPage)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 15)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
// MARK: - Counter Badge (below name capsule)
|
||||
|
||||
private var counterBadge: some View {
|
||||
Text("\(currentPage + 1) of \(state.images.count)")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
.background { TelegramGlassCapsule() }
|
||||
.contentTransition(.numericText())
|
||||
.animation(.easeInOut, value: currentPage)
|
||||
}
|
||||
|
||||
// MARK: - Bottom Panel
|
||||
|
||||
private var bottomPanel: some View {
|
||||
HStack {
|
||||
glassCircleButton(systemName: "arrowshape.turn.up.right") { }
|
||||
Spacer(minLength: 0)
|
||||
glassCircleButton(systemName: "square.and.arrow.up") { shareCurrentImage() }
|
||||
Spacer(minLength: 0)
|
||||
glassCircleButton(systemName: "square.and.arrow.down") { saveCurrentImage() }
|
||||
Spacer(minLength: 0)
|
||||
glassCircleButton(systemName: "trash") { }
|
||||
}
|
||||
.padding(.horizontal, 15)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
// MARK: - Glass Circle Button
|
||||
|
||||
private func glassCircleButton(systemName: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: systemName)
|
||||
.font(.title3)
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 36, height: 36)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.background { TelegramGlassCircle() }
|
||||
}
|
||||
|
||||
private func glassLabel(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, 15)
|
||||
.padding(.vertical, 10)
|
||||
.background { TelegramGlassCapsule() }
|
||||
}
|
||||
|
||||
// MARK: - Navigation
|
||||
|
||||
private func navigateEdgeTap(direction: Int) {
|
||||
@@ -278,7 +407,9 @@ struct ImageGalleryViewer: View {
|
||||
// MARK: - Dismiss
|
||||
|
||||
private func dismissAction() {
|
||||
if currentZoomScale > 1.05 {
|
||||
// Telegram parity: hero-back only for the initially-tapped photo.
|
||||
// If user paged away, the sourceFrame belongs to a different thumbnail — fade instead.
|
||||
if currentZoomScale > 1.05 || currentPage != state.initialIndex {
|
||||
fadeDismiss()
|
||||
} else {
|
||||
heroDismiss()
|
||||
@@ -288,10 +419,11 @@ struct ImageGalleryViewer: View {
|
||||
private func heroDismiss() {
|
||||
guard !isDismissing else { return }
|
||||
isDismissing = true
|
||||
panCoordinator.isEnabled = false
|
||||
|
||||
Task {
|
||||
withAnimation(heroAnimation.speed(1.2)) {
|
||||
dragOffset = .zero
|
||||
panCoordinator.dragOffset = .zero
|
||||
isExpanded = false
|
||||
}
|
||||
try? await Task.sleep(for: .seconds(0.35))
|
||||
@@ -302,12 +434,15 @@ struct ImageGalleryViewer: View {
|
||||
private func fadeDismiss() {
|
||||
guard !isDismissing else { return }
|
||||
isDismissing = true
|
||||
panCoordinator.isEnabled = false
|
||||
|
||||
withAnimation(.easeOut(duration: 0.2)) {
|
||||
isExpanded = false
|
||||
// Slide down + fade out via dragOffset (drives backgroundOpacity toward 0).
|
||||
// Do NOT set isExpanded=false — that collapses to sourceFrame (wrong thumbnail).
|
||||
withAnimation(.easeOut(duration: 0.25)) {
|
||||
panCoordinator.dragOffset = CGSize(width: 0, height: viewSize.height * 0.4)
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.27) {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
@@ -362,70 +497,3 @@ struct ImageGalleryViewer: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HeroPanGestureOverlay
|
||||
|
||||
/// Transparent UIView overlay with UIPanGestureRecognizer for vertical hero dismiss.
|
||||
/// Uses UIKit gesture (not UIGestureRecognizerRepresentable) for iOS 17+ compat.
|
||||
/// Matches PanGesture from PhotosTransition reference — vertical only, single touch.
|
||||
private struct HeroPanGestureOverlay: UIViewRepresentable {
|
||||
var onPan: (UIPanGestureRecognizer) -> Void
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
let view = UIView()
|
||||
view.backgroundColor = .clear
|
||||
|
||||
let pan = UIPanGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handlePan(_:)))
|
||||
pan.minimumNumberOfTouches = 1
|
||||
pan.maximumNumberOfTouches = 1
|
||||
pan.delegate = context.coordinator
|
||||
view.addGestureRecognizer(pan)
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIView, context: Context) {
|
||||
context.coordinator.onPan = onPan
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(onPan: onPan) }
|
||||
|
||||
final class Coordinator: NSObject, UIGestureRecognizerDelegate {
|
||||
var onPan: (UIPanGestureRecognizer) -> Void
|
||||
|
||||
init(onPan: @escaping (UIPanGestureRecognizer) -> Void) {
|
||||
self.onPan = onPan
|
||||
}
|
||||
|
||||
@objc func handlePan(_ gesture: UIPanGestureRecognizer) {
|
||||
onPan(gesture)
|
||||
}
|
||||
|
||||
// Only begin for downward vertical drags
|
||||
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return false }
|
||||
let velocity = pan.velocity(in: pan.view)
|
||||
return velocity.y > abs(velocity.x)
|
||||
}
|
||||
|
||||
// Let TabView scroll pass through when scrolled down
|
||||
func gestureRecognizer(
|
||||
_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer
|
||||
) -> Bool {
|
||||
if let scrollView = otherGestureRecognizer.view as? UIScrollView {
|
||||
return scrollView.contentOffset.y <= 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Allow simultaneous recognition with other gestures (taps, pinch, etc.)
|
||||
func gestureRecognizer(
|
||||
_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
|
||||
) -> Bool {
|
||||
return !(otherGestureRecognizer is UIPanGestureRecognizer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,8 +102,8 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
case .dark, .light:
|
||||
return traitCollection.userInterfaceStyle
|
||||
default:
|
||||
let prefersDark = UserDefaults.standard.object(forKey: "rosetta_dark_mode") as? Bool ?? true
|
||||
return prefersDark ? .dark : .light
|
||||
let themeMode = UserDefaults.standard.string(forKey: "rosetta_theme_mode") ?? "dark"
|
||||
return themeMode == "light" ? .light : .dark
|
||||
}
|
||||
}
|
||||
private static let blurHashCache: NSCache<NSString, UIImage> = {
|
||||
@@ -519,6 +519,10 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
longPress.minimumPressDuration = 0.35
|
||||
bubbleView.addGestureRecognizer(longPress)
|
||||
|
||||
// Single tap → open link if tapped on a URL
|
||||
let linkTap = UITapGestureRecognizer(target: self, action: #selector(handleLinkTap(_:)))
|
||||
bubbleView.addGestureRecognizer(linkTap)
|
||||
|
||||
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleSwipe(_:)))
|
||||
pan.delegate = self
|
||||
contentView.addGestureRecognizer(pan)
|
||||
@@ -546,7 +550,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
let isOutgoing = currentLayout?.isOutgoing ?? false
|
||||
let isMediaStatus: Bool = {
|
||||
guard let type = currentLayout?.messageType else { return false }
|
||||
return type == .photo || type == .photoWithCaption
|
||||
return type == .photo || type == .photoWithCaption || type == .emojiOnly
|
||||
}()
|
||||
|
||||
// Text — use cached CoreTextTextLayout from measurement phase.
|
||||
@@ -907,6 +911,12 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
bubbleOutlineLayer.strokeColor = UIColor.clear.cgColor
|
||||
}
|
||||
|
||||
// Emoji-only: hide bubble visuals (no background, just floating emoji)
|
||||
let isEmojiOnly = layout.messageType == .emojiOnly
|
||||
bubbleImageView.isHidden = isEmojiOnly
|
||||
bubbleLayer.isHidden = isEmojiOnly
|
||||
bubbleOutlineLayer.isHidden = isEmojiOnly
|
||||
|
||||
// Text
|
||||
textLabel.isHidden = layout.textSize == .zero
|
||||
textLabel.frame = layout.textFrame
|
||||
@@ -1287,6 +1297,18 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
return attrs
|
||||
}
|
||||
|
||||
// MARK: - Link Tap
|
||||
|
||||
@objc private func handleLinkTap(_ gesture: UITapGestureRecognizer) {
|
||||
let pointInText = gesture.location(in: textLabel)
|
||||
guard let url = textLabel.textLayout?.linkAt(point: pointInText) else { return }
|
||||
var finalURL = url
|
||||
if finalURL.scheme == nil || finalURL.scheme?.isEmpty == true {
|
||||
finalURL = URL(string: "https://\(url.absoluteString)") ?? url
|
||||
}
|
||||
UIApplication.shared.open(finalURL)
|
||||
}
|
||||
|
||||
// MARK: - Context Menu (Telegram-style)
|
||||
|
||||
private let contextMenuHaptic = UIImpactFeedbackGenerator(style: .medium)
|
||||
@@ -2257,7 +2279,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
return
|
||||
}
|
||||
// Telegram uses a dedicated status background on media messages.
|
||||
statusBackgroundView.isHidden = layout.messageType != .photo
|
||||
statusBackgroundView.isHidden = layout.messageType != .photo && layout.messageType != .emojiOnly
|
||||
}
|
||||
|
||||
private func updateStatusBackgroundFrame() {
|
||||
@@ -2287,6 +2309,8 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
|
||||
#if DEBUG
|
||||
private func assertStatusLaneFramesValid(layout: MessageCellLayout) {
|
||||
// emojiOnly has no visible bubble — status pill floats below emoji
|
||||
guard layout.messageType != .emojiOnly else { return }
|
||||
let bubbleBounds = CGRect(origin: .zero, size: layout.bubbleSize)
|
||||
let frames = [
|
||||
("timestamp", layout.timestampFrame),
|
||||
@@ -2340,6 +2364,9 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
wasSentCheckVisible = false
|
||||
wasReadCheckVisible = false
|
||||
statusBackgroundView.isHidden = true
|
||||
bubbleImageView.isHidden = false
|
||||
bubbleLayer.isHidden = false
|
||||
bubbleOutlineLayer.isHidden = false
|
||||
resetPhotoTiles()
|
||||
replyContainer.isHidden = true
|
||||
replyMessageId = nil
|
||||
|
||||
@@ -976,7 +976,8 @@ final class NativeMessageListController: UIViewController {
|
||||
textLayoutCache.removeAll()
|
||||
return
|
||||
}
|
||||
let isDark = UserDefaults.standard.object(forKey: "rosetta_dark_mode") as? Bool ?? true
|
||||
let themeMode = UserDefaults.standard.string(forKey: "rosetta_theme_mode") ?? "dark"
|
||||
let isDark = themeMode != "light"
|
||||
let (layouts, textLayouts) = MessageCellLayout.batchCalculate(
|
||||
messages: messages,
|
||||
maxBubbleWidth: config.maxBubbleWidth,
|
||||
|
||||
@@ -124,358 +124,3 @@ struct ZoomableImagePage: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIViewRepresentable
|
||||
|
||||
/// Wraps `ImageGestureContainerView` for SwiftUI integration.
|
||||
private struct ZoomableImageUIViewRepresentable: UIViewRepresentable {
|
||||
|
||||
let image: UIImage
|
||||
let onDismiss: () -> Void
|
||||
let onDismissProgress: (CGFloat) -> Void
|
||||
let onDismissCancel: () -> Void
|
||||
let onToggleControls: () -> Void
|
||||
let onScaleChanged: (CGFloat) -> Void
|
||||
let onEdgeTap: ((Int) -> Void)?
|
||||
|
||||
func makeUIView(context: Context) -> ImageGestureContainerView {
|
||||
let view = ImageGestureContainerView(image: image)
|
||||
view.onDismiss = onDismiss
|
||||
view.onDismissProgress = onDismissProgress
|
||||
view.onDismissCancel = onDismissCancel
|
||||
view.onToggleControls = onToggleControls
|
||||
view.onScaleChanged = onScaleChanged
|
||||
view.onEdgeTap = onEdgeTap
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ view: ImageGestureContainerView, context: Context) {
|
||||
view.onDismiss = onDismiss
|
||||
view.onDismissProgress = onDismissProgress
|
||||
view.onDismissCancel = onDismissCancel
|
||||
view.onToggleControls = onToggleControls
|
||||
view.onScaleChanged = onScaleChanged
|
||||
view.onEdgeTap = onEdgeTap
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ImageGestureContainerView
|
||||
|
||||
/// UIKit view that handles all image gestures with full control:
|
||||
/// - Centroid-based pinch zoom (1x–5x)
|
||||
/// - Double-tap to zoom to tap point (2.5x) or reset
|
||||
/// - Pan when zoomed (with offset clamping)
|
||||
/// - Vertical drag to dismiss with velocity tracking
|
||||
/// - Single tap: edge zones navigate, center toggles controls
|
||||
/// - Axis locking: decides vertical dismiss vs pan early
|
||||
///
|
||||
/// Android parity: `ZoomableImage` in `ImageViewerScreen.kt`
|
||||
final class ImageGestureContainerView: UIView, UIGestureRecognizerDelegate {
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
private let minScale: CGFloat = 1.0
|
||||
private let maxScale: CGFloat = 5.0
|
||||
private let doubleTapScale: CGFloat = 2.5
|
||||
private let dismissDistanceThreshold: CGFloat = 100
|
||||
private let dismissVelocityThreshold: CGFloat = 500
|
||||
private let touchSlop: CGFloat = 20
|
||||
/// Android: left/right 20% zones are edge-tap navigation areas.
|
||||
private let edgeTapFraction: CGFloat = 0.20
|
||||
/// Android: spring(dampingRatio = 0.9, stiffness = 400) ≈ UIKit(damping: 0.9, velocity: 0)
|
||||
private let springDamping: CGFloat = 0.9
|
||||
private let springDuration: CGFloat = 0.35
|
||||
|
||||
// MARK: - Subviews
|
||||
|
||||
private let imageView = UIImageView()
|
||||
private var panGesture: UIPanGestureRecognizer?
|
||||
|
||||
// MARK: - Transform State
|
||||
|
||||
private var currentScale: CGFloat = 1.0
|
||||
private var currentOffset: CGPoint = .zero
|
||||
private var dismissOffset: CGFloat = 0
|
||||
|
||||
// Pinch gesture tracking
|
||||
private var pinchStartScale: CGFloat = 1.0
|
||||
private var pinchStartOffset: CGPoint = .zero
|
||||
private var lastPinchCentroid: CGPoint = .zero
|
||||
|
||||
// Pan gesture tracking
|
||||
private var panStartOffset: CGPoint = .zero
|
||||
private var isDismissGesture = false
|
||||
private var gestureAxisLocked = false
|
||||
|
||||
// MARK: - Callbacks
|
||||
|
||||
var onDismiss: (() -> Void)?
|
||||
var onDismissProgress: ((CGFloat) -> Void)?
|
||||
var onDismissCancel: (() -> Void)?
|
||||
var onToggleControls: (() -> Void)?
|
||||
var onScaleChanged: ((CGFloat) -> Void)?
|
||||
/// -1 = left edge, 1 = right edge
|
||||
var onEdgeTap: ((Int) -> Void)?
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init(image: UIImage) {
|
||||
super.init(frame: .zero)
|
||||
imageView.image = image
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.isUserInteractionEnabled = false
|
||||
addSubview(imageView)
|
||||
clipsToBounds = true
|
||||
setupGestures()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
/// Track the last laid-out size so we only reset frame when it actually changes.
|
||||
/// Without this, SwiftUI state changes (e.g. `onDismissProgress`) trigger
|
||||
/// `layoutSubviews` → `imageView.frame = bounds` which RESETS the UIKit transform,
|
||||
/// causing the image to snap back during dismiss drag.
|
||||
private var lastLayoutSize: CGSize = .zero
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
guard lastLayoutSize != bounds.size else { return }
|
||||
lastLayoutSize = bounds.size
|
||||
// Temporarily reset transform, update frame, then re-apply.
|
||||
let savedTransform = imageView.transform
|
||||
imageView.transform = .identity
|
||||
imageView.frame = bounds
|
||||
imageView.transform = savedTransform
|
||||
}
|
||||
|
||||
// MARK: - Gesture Setup
|
||||
|
||||
private func setupGestures() {
|
||||
let pinch = UIPinchGestureRecognizer(target: self, action: #selector(handlePinch))
|
||||
pinch.delegate = self
|
||||
addGestureRecognizer(pinch)
|
||||
|
||||
// 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
|
||||
addGestureRecognizer(doubleTap)
|
||||
|
||||
let singleTap = UITapGestureRecognizer(target: self, action: #selector(handleSingleTap))
|
||||
singleTap.numberOfTapsRequired = 1
|
||||
singleTap.require(toFail: doubleTap)
|
||||
addGestureRecognizer(singleTap)
|
||||
}
|
||||
|
||||
// MARK: - Apply Transform
|
||||
|
||||
private func applyTransform(animated: Bool = false) {
|
||||
// Guard against NaN/Infinity — prevents CoreGraphics crash and UI freeze.
|
||||
if currentScale.isNaN || currentScale.isInfinite { currentScale = 1.0 }
|
||||
if currentOffset.x.isNaN || currentOffset.x.isInfinite { currentOffset.x = 0 }
|
||||
if currentOffset.y.isNaN || currentOffset.y.isInfinite { currentOffset.y = 0 }
|
||||
if dismissOffset.isNaN || dismissOffset.isInfinite { dismissOffset = 0 }
|
||||
|
||||
let transform = CGAffineTransform.identity
|
||||
.translatedBy(x: currentOffset.x, y: currentOffset.y + dismissOffset)
|
||||
.scaledBy(x: currentScale, y: currentScale)
|
||||
|
||||
if animated {
|
||||
UIView.animate(
|
||||
withDuration: springDuration,
|
||||
delay: 0,
|
||||
usingSpringWithDamping: springDamping,
|
||||
initialSpringVelocity: 0,
|
||||
options: [.curveEaseOut]
|
||||
) {
|
||||
self.imageView.transform = transform
|
||||
}
|
||||
} else {
|
||||
imageView.transform = transform
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pinch Gesture (Centroid Zoom)
|
||||
|
||||
@objc private func handlePinch(_ gesture: UIPinchGestureRecognizer) {
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
pinchStartScale = currentScale
|
||||
pinchStartOffset = currentOffset
|
||||
if gesture.numberOfTouches >= 2 {
|
||||
lastPinchCentroid = gesture.location(in: self)
|
||||
}
|
||||
|
||||
case .changed:
|
||||
let newScale = min(max(pinchStartScale * gesture.scale, minScale * 0.5), maxScale)
|
||||
|
||||
// Centroid-based zoom: keep the point under fingers stationary
|
||||
if gesture.numberOfTouches >= 2 {
|
||||
let centroid = gesture.location(in: self)
|
||||
let viewCenter = CGPoint(x: bounds.midX, y: bounds.midY)
|
||||
let gesturePoint = CGPoint(x: centroid.x - viewCenter.x, y: centroid.y - viewCenter.y)
|
||||
|
||||
let safeCurrentScale = max(currentScale, 0.01)
|
||||
let scaleRatio = newScale / safeCurrentScale
|
||||
guard scaleRatio.isFinite else { break }
|
||||
currentOffset = CGPoint(
|
||||
x: gesturePoint.x - (gesturePoint.x - currentOffset.x) * scaleRatio,
|
||||
y: gesturePoint.y - (gesturePoint.y - currentOffset.y) * scaleRatio
|
||||
)
|
||||
lastPinchCentroid = centroid
|
||||
}
|
||||
|
||||
currentScale = newScale
|
||||
onScaleChanged?(currentScale)
|
||||
applyTransform()
|
||||
|
||||
case .ended, .cancelled:
|
||||
if currentScale < minScale + 0.05 {
|
||||
// Snap back to 1x
|
||||
currentScale = minScale
|
||||
currentOffset = .zero
|
||||
onScaleChanged?(minScale)
|
||||
applyTransform(animated: true)
|
||||
} else {
|
||||
clampOffset(animated: true)
|
||||
}
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
panStartOffset = currentOffset
|
||||
|
||||
case .changed:
|
||||
currentOffset = CGPoint(
|
||||
x: panStartOffset.x + translation.x,
|
||||
y: panStartOffset.y + translation.y
|
||||
)
|
||||
applyTransform()
|
||||
|
||||
case .ended, .cancelled:
|
||||
clampOffset(animated: true)
|
||||
gestureAxisLocked = false
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Double Tap (Zoom to tap point)
|
||||
|
||||
@objc private func handleDoubleTap(_ gesture: UITapGestureRecognizer) {
|
||||
let tapPoint = gesture.location(in: self)
|
||||
let viewCenter = CGPoint(x: bounds.midX, y: bounds.midY)
|
||||
|
||||
if currentScale > 1.1 {
|
||||
// Zoom out to 1x
|
||||
currentScale = minScale
|
||||
currentOffset = .zero
|
||||
onScaleChanged?(minScale)
|
||||
applyTransform(animated: true)
|
||||
} else {
|
||||
// Zoom in to tap point at 2.5x (Android: tapX - tapX * targetScale)
|
||||
let tapX = tapPoint.x - viewCenter.x
|
||||
let tapY = tapPoint.y - viewCenter.y
|
||||
|
||||
currentScale = doubleTapScale
|
||||
currentOffset = CGPoint(
|
||||
x: tapX - tapX * doubleTapScale,
|
||||
y: tapY - tapY * doubleTapScale
|
||||
)
|
||||
clampOffsetImmediate()
|
||||
onScaleChanged?(doubleTapScale)
|
||||
applyTransform(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Single Tap (Edge navigation or toggle controls)
|
||||
|
||||
@objc private func handleSingleTap(_ gesture: UITapGestureRecognizer) {
|
||||
guard currentScale <= 1.05 else {
|
||||
// When zoomed, single tap always toggles controls
|
||||
onToggleControls?()
|
||||
return
|
||||
}
|
||||
|
||||
let tapX = gesture.location(in: self).x
|
||||
let width = bounds.width
|
||||
let edgeZone = width * edgeTapFraction
|
||||
|
||||
if tapX < edgeZone {
|
||||
onEdgeTap?(-1) // Previous
|
||||
} else if tapX > width - edgeZone {
|
||||
onEdgeTap?(1) // Next
|
||||
} else {
|
||||
onToggleControls?()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Offset Clamping
|
||||
|
||||
private func clampOffset(animated: Bool) {
|
||||
guard currentScale > 1.0 else {
|
||||
currentOffset = .zero
|
||||
applyTransform(animated: animated)
|
||||
return
|
||||
}
|
||||
let clamped = clampedOffset()
|
||||
if currentOffset != clamped {
|
||||
currentOffset = clamped
|
||||
applyTransform(animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
private func clampOffsetImmediate() {
|
||||
currentOffset = clampedOffset()
|
||||
}
|
||||
|
||||
private func clampedOffset() -> CGPoint {
|
||||
let maxX = max(bounds.width * (currentScale - 1) / 2, 0)
|
||||
let maxY = max(bounds.height * (currentScale - 1) / 2, 0)
|
||||
return CGPoint(
|
||||
x: min(max(currentOffset.x, -maxX), maxX),
|
||||
y: min(max(currentOffset.y, -maxY), maxY)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - UIGestureRecognizerDelegate
|
||||
|
||||
func gestureRecognizer(
|
||||
_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer
|
||||
) -> Bool {
|
||||
// Allow pinch + pan simultaneously (zoom + drag)
|
||||
let isPinchPan = (gestureRecognizer is UIPinchGestureRecognizer && other is UIPanGestureRecognizer) ||
|
||||
(gestureRecognizer is UIPanGestureRecognizer && other is UIPinchGestureRecognizer)
|
||||
return isPinchPan
|
||||
}
|
||||
|
||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
if gestureRecognizer is UIPanGestureRecognizer {
|
||||
// Pan only when zoomed — dismiss handled by SwiftUI DragGesture
|
||||
return currentScale > 1.05
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,6 +288,9 @@ private extension ChatRowView {
|
||||
// Previously hidden when lastMessageFromMe (desktop parity),
|
||||
// but this caused invisible unreads when user sent a reply
|
||||
// without reading prior incoming messages first.
|
||||
if dialog.hasMention && dialog.unreadCount > 0 && !isSyncing {
|
||||
mentionBadge
|
||||
}
|
||||
if dialog.unreadCount > 0 && !isSyncing {
|
||||
unreadBadge
|
||||
}
|
||||
@@ -296,6 +299,18 @@ private extension ChatRowView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Telegram-style `@` mention indicator (shown left of unread count).
|
||||
var mentionBadge: some View {
|
||||
Text("@")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 20, height: 20)
|
||||
.background {
|
||||
Circle()
|
||||
.fill(dialog.isMuted ? Color(hex: 0x787880) : RosettaColors.figmaBlue)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var deliveryIcon: some View {
|
||||
if dialog.lastMessageDelivered == .delivered && dialog.lastMessageRead {
|
||||
|
||||
Reference in New Issue
Block a user