Фикс: бэкграунд звонки — аудио, имя на CallKit, deactivation order, UUID race

This commit is contained in:
2026-04-06 00:18:37 +05:00
parent d65624ad35
commit 55cb120db3
32 changed files with 1548 additions and 688 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 (1x5x)
/// - 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
}
}

View File

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