feat: группы — inline карточка приглашения (Desktop/Android parity) + навигация pop→push fix

This commit is contained in:
2026-04-06 02:07:06 +05:00
parent cdb6c7e51e
commit 333908a4d9
12 changed files with 692 additions and 266 deletions

View File

@@ -39,7 +39,7 @@ final class GroupRepository {
let description: String
}
struct ParsedGroupInvite {
struct ParsedGroupInvite: Sendable {
let groupId: String
let title: String
let encryptKey: String
@@ -53,6 +53,11 @@ final class GroupRepository {
}
func normalizeGroupId(_ value: String) -> String {
Self.normalizeGroupIdPure(value)
}
/// Thread-safe group ID normalization strips `#group:`, `group:`, `conversation:` prefixes.
nonisolated static func normalizeGroupIdPure(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
let lower = trimmed.lowercased()
if lower.hasPrefix("#group:") {
@@ -193,6 +198,11 @@ final class GroupRepository {
/// Parses an invite string into its components.
func parseInviteString(_ inviteString: String) -> ParsedGroupInvite? {
Self.parseInviteStringPure(inviteString)
}
/// Thread-safe (nonisolated) parsing callable from background layout threads.
nonisolated static func parseInviteStringPure(_ inviteString: String) -> ParsedGroupInvite? {
let trimmed = inviteString.trimmingCharacters(in: .whitespacesAndNewlines)
let lower = trimmed.lowercased()
@@ -211,7 +221,7 @@ final class GroupRepository {
guard let decryptedPayload = try? CryptoManager.shared.decryptWithPassword(
encodedPayload,
password: Self.groupInvitePassword
password: groupInvitePassword
), let payload = String(data: decryptedPayload, encoding: .utf8) else {
return nil
}
@@ -219,7 +229,7 @@ final class GroupRepository {
let parts = payload.split(separator: ":", omittingEmptySubsequences: false).map(String.init)
guard parts.count >= 3 else { return nil }
let groupId = normalizeGroupId(parts[0])
let groupId = normalizeGroupIdPure(parts[0])
guard !groupId.isEmpty else { return nil }
return ParsedGroupInvite(
@@ -230,6 +240,13 @@ final class GroupRepository {
)
}
/// Fast local membership check uses in-memory keyCache. MainActor only.
func hasGroup(for groupId: String) -> Bool {
let account = SessionManager.shared.currentPublicKey
let key = cacheKey(account: account, groupId: groupId)
return keyCache[key] != nil
}
// MARK: - Persistence
/// Persists group from a joined `PacketGroupJoin` (server-pushed or self-initiated).

View File

@@ -60,6 +60,12 @@ struct MessageCellLayout: Sendable {
let hasFile: Bool
let fileFrame: CGRect // File view frame in bubble coords
// MARK: - Group Invite (optional)
let hasGroupInvite: Bool
let groupInviteTitle: String
let groupInviteGroupId: String
// MARK: - Forward Header (optional)
let isForward: Bool
@@ -90,6 +96,7 @@ struct MessageCellLayout: Sendable {
case file
case forward
case emojiOnly
case groupInvite
}
}
@@ -119,6 +126,9 @@ extension MessageCellLayout {
let forwardCaption: String?
let showsDateHeader: Bool
let dateHeaderText: String
let groupInviteCount: Int
let groupInviteTitle: String
let groupInviteGroupId: String
}
private struct MediaDimensions {
@@ -186,6 +196,8 @@ extension MessageCellLayout {
messageType = .photo
} else if config.fileCount > 0 || config.avatarCount > 0 || config.callCount > 0 {
messageType = .file
} else if config.groupInviteCount > 0 {
messageType = .groupInvite
} else if config.hasReplyQuote {
messageType = .textWithReply
} else {
@@ -468,13 +480,24 @@ extension MessageCellLayout {
fileOnlyTsPad = tsPad
bubbleH += tsGap + tsSize.height + tsPad
fileH = bubbleH // fileContainer spans entire bubble
} else if config.groupInviteCount > 0 {
// Group invite card: icon row + status + button
let inviteCardH: CGFloat = 80
let inviteMinW: CGFloat = 220
bubbleW = min(inviteMinW, effectiveMaxBubbleWidth)
bubbleW = max(bubbleW, leftPad + metadataWidth + rightPad)
let tsGap: CGFloat = 6
let contentH: CGFloat = 60
let tsPad = ceil((inviteCardH + tsGap - contentH) / 2)
fileOnlyTsPad = tsPad
bubbleH += inviteCardH + tsGap + tsSize.height + tsPad
} else {
// No text, no file (forward header only, empty)
bubbleW = leftPad + metadataWidth + rightPad
bubbleW = max(minW, min(bubbleW, effectiveMaxBubbleWidth))
}
if config.text.isEmpty && photoH == 0 && fileH == 0 && !config.isForward {
if config.text.isEmpty && photoH == 0 && fileH == 0 && !config.isForward && config.groupInviteCount == 0 {
bubbleH = max(bubbleH, 37)
}
// Forward header needs minimum width for "Forwarded from" + avatar + name
@@ -672,6 +695,9 @@ extension MessageCellLayout {
photoCollageHeight: photoH,
hasFile: config.fileCount > 0 || config.avatarCount > 0 || config.callCount > 0,
fileFrame: fileFrame,
hasGroupInvite: config.groupInviteCount > 0,
groupInviteTitle: config.groupInviteTitle,
groupInviteGroupId: config.groupInviteGroupId,
isForward: config.isForward,
forwardHeaderFrame: fwdHeaderFrame,
forwardAvatarFrame: fwdAvatarFrame,
@@ -1001,13 +1027,24 @@ extension MessageCellLayout {
AttachmentPreviewCodec.imageDimensions(from: $0.preview)
}
// Detect group invite (only for text-only messages, no attachments)
let isGroupInvite = displayText.hasPrefix("#group:")
&& images.isEmpty && files.isEmpty && avatars.isEmpty && calls.isEmpty && !isForward
var groupInviteTitle = ""
var groupInviteGroupId = ""
if isGroupInvite, let parsed = GroupRepository.parseInviteStringPure(displayText) {
groupInviteTitle = parsed.title
groupInviteGroupId = parsed.groupId
}
let groupInviteCount = (!groupInviteTitle.isEmpty) ? 1 : 0
let config = Config(
maxBubbleWidth: maxBubbleWidth,
isOutgoing: isOutgoing,
isDarkMode: isDarkMode,
position: position,
deliveryStatus: message.deliveryStatus,
text: isForward ? (forwardCaption ?? "") : displayText,
text: isForward ? (forwardCaption ?? "") : (groupInviteCount > 0 ? "" : displayText),
timestampText: timestampText,
hasReplyQuote: hasReply && !displayText.isEmpty,
replyName: nil,
@@ -1022,7 +1059,10 @@ extension MessageCellLayout {
forwardFileCount: forwardInnerFileCount,
forwardCaption: forwardCaption,
showsDateHeader: showsDateHeader,
dateHeaderText: dateHeaderText
dateHeaderText: dateHeaderText,
groupInviteCount: groupInviteCount,
groupInviteTitle: groupInviteTitle,
groupInviteGroupId: groupInviteGroupId
)
var (layout, textLayout) = calculate(config: config)

View File

@@ -255,6 +255,16 @@ struct ChatDetailView: View {
pendingGroupInviteTitle = parsed.title
}
}
cellActions.onGroupInviteOpen = { dialogKey in
let title = GroupRepository.shared.groupMetadata(
account: SessionManager.shared.currentPublicKey,
groupDialogKey: dialogKey
)?.title ?? ""
NotificationCenter.default.post(
name: .openChatFromNotification,
object: ChatRoute(groupDialogKey: dialogKey, title: title)
)
}
// Capture first unread incoming message BEFORE marking as read.
if firstUnreadMessageId == nil {
firstUnreadMessageId = messages.first(where: {

View File

@@ -0,0 +1,113 @@
import SwiftUI
/// Inline card for group invite links renders inside message bubble (iOS 26+ SwiftUI path).
/// Matches Desktop `GroupInviteMessage` and Android `GroupInviteInlineCard` behavior.
struct GroupInviteCardView: View {
let inviteString: String
let title: String
let groupId: String
let isOutgoing: Bool
let actions: MessageCellActions
@State private var status: CardStatus = .notJoined
enum CardStatus {
case notJoined, joined, invalid, banned
var color: Color {
switch self {
case .notJoined: Color(red: 0.14, green: 0.54, blue: 0.90)
case .joined: Color(red: 0.20, green: 0.78, blue: 0.35)
case .invalid, .banned: Color(red: 0.98, green: 0.23, blue: 0.19)
}
}
var statusText: String {
switch self {
case .notJoined: "Invite to join this group"
case .joined: "You are a member"
case .invalid: "This invite is invalid"
case .banned: "You are banned"
}
}
var buttonTitle: String {
switch self {
case .notJoined: "Join Group"
case .joined: "Open Group"
case .invalid: "Invalid"
case .banned: "Banned"
}
}
var isActionable: Bool {
self == .notJoined || self == .joined
}
}
var body: some View {
HStack(alignment: .top, spacing: 10) {
Circle()
.fill(status.color)
.frame(width: 44, height: 44)
.overlay {
Image(systemName: "person.2.fill")
.font(.system(size: 18, weight: .medium))
.foregroundStyle(.white)
}
VStack(alignment: .leading, spacing: 3) {
Text(title.isEmpty ? "Group" : title)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(isOutgoing ? .white : Color(status.color))
.lineLimit(1)
Text(status.statusText)
.font(.system(size: 12))
.foregroundStyle(isOutgoing ? .white.opacity(0.7) : .secondary)
.lineLimit(1)
Button(action: handleTap) {
Text(status.buttonTitle)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, 14)
.padding(.vertical, 5)
.background(status.color, in: Capsule())
}
.buttonStyle(.plain)
.disabled(!status.isActionable)
.opacity(status.isActionable ? 1.0 : 0.5)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.task { await checkStatus() }
}
private func handleTap() {
if status == .joined {
let dialogKey = "#group:\(groupId)"
actions.onGroupInviteOpen(dialogKey)
} else if status == .notJoined {
actions.onGroupInviteTap(inviteString)
}
}
private func checkStatus() async {
// Local check first
let isJoined = GroupRepository.shared.hasGroup(for: groupId)
if isJoined { status = .joined }
// Server check for authoritative status
if let (_, serverStatus) = try? await GroupService.shared.checkInviteStatus(groupId: groupId) {
let newStatus: CardStatus = switch serverStatus {
case .joined: .joined
case .notJoined: .notJoined
case .invalid: .invalid
case .banned: .banned
}
status = newStatus
}
}
}

View File

@@ -5,18 +5,14 @@ import Photos
// MARK: - Data Types
/// Per-image metadata for the gallery viewer.
/// Android parity: `ViewableImage` in `ImageViewerScreen.kt`.
struct ViewableImageInfo: Equatable, Identifiable {
let attachmentId: String
let senderName: String
let timestamp: Date
let caption: String
var id: String { attachmentId }
}
/// State for the image gallery viewer.
struct ImageViewerState: Equatable {
let images: [ViewableImageInfo]
let initialIndex: Int
@@ -28,16 +24,10 @@ struct ImageViewerState: Equatable {
/// 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) {
@@ -45,49 +35,38 @@ final class GalleryDismissPanCoordinator: NSObject, ObservableObject, UIGestureR
if gesture.state == .began { gesture.state = .cancelled }
return
}
let translation = gesture.translation(in: gesture.view)
let t = 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)
dragOffset = CGSize(width: 0, height: t.y)
case .ended, .cancelled:
endVelocityY = gesture.velocity(in: gesture.view).y
panEndSignal.toggle()
default:
break
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 }
func gestureRecognizerShouldBegin(_ g: UIGestureRecognizer) -> Bool {
guard isEnabled, let pan = g 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)
func gestureRecognizer(_ g: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith o: UIGestureRecognizer) -> Bool {
!(o is UIPanGestureRecognizer)
}
}
// MARK: - ImageViewerPresenter
/// UIHostingController subclass that hides the status bar.
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.
/// 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?
@@ -104,13 +83,10 @@ final class ImageViewerPresenter {
onDismiss: { [weak self] in self?.dismiss() }
)
let hostingController = StatusBarHiddenHostingController(rootView: AnyView(viewer))
hostingController.modalPresentationStyle = .overFullScreen
hostingController.view.backgroundColor = .clear
let hc = StatusBarHiddenHostingController(rootView: AnyView(viewer))
hc.modalPresentationStyle = .overFullScreen
hc.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)
@@ -118,18 +94,15 @@ final class ImageViewerPresenter {
pan.minimumNumberOfTouches = 1
pan.maximumNumberOfTouches = 1
pan.delegate = coordinator
hostingController.view.addGestureRecognizer(pan)
hc.view.addGestureRecognizer(pan)
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let root = windowScene.keyWindow?.rootViewController
else { return }
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let root = scene.keyWindow?.rootViewController else { return }
var presenter = root
while let presented = presenter.presentedViewController {
presenter = presented
}
presenter.present(hostingController, animated: false)
presentedController = hostingController
while let p = presenter.presentedViewController { presenter = p }
presenter.present(hc, animated: false)
presentedController = hc
}
func dismiss() {
@@ -139,11 +112,16 @@ final class ImageViewerPresenter {
}
}
// MARK: - Window Safe Area Helper
private var windowSafeArea: UIEdgeInsets {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first?.keyWindow?.safeAreaInsets ?? .zero
}
// MARK: - ImageGalleryViewer
/// Multi-photo gallery viewer with hero transition animation.
/// 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
@@ -155,13 +133,13 @@ struct ImageGalleryViewer: View {
@State private var currentZoomScale: CGFloat = 1.0
@State private var isDismissing = false
@State private var isExpanded: Bool = false
@State private var viewSize: CGSize = UIScreen.main.bounds.size
private let screenSize = UIScreen.main.bounds.size
private static let dateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .none
f.timeStyle = .short
f.doesRelativeDateFormatting = true
return f
}()
@@ -188,36 +166,28 @@ struct ImageGalleryViewer: View {
.interpolatingSpring(duration: 0.3, bounce: 0, initialVelocity: 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)"
let day = Self.relativeDateFormatter.string(from: date)
let time = Self.dateFormatter.string(from: date)
return "\(day) at \(time)"
}
// MARK: - Body
var body: some View {
let sourceFrame = state.sourceFrame
GeometryReader { geometry in
let size = geometry.size
let sf = state.sourceFrame
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
@@ -226,64 +196,45 @@ struct ImageGalleryViewer: View {
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
onEdgeTap: { dir in navigateEdgeTap(direction: dir) }
)
// Hero page: animate from sourceFrame fullscreen.
// Non-hero pages: NO explicit frame fill TabView page naturally.
.modifier(GalleryPageModifier(
heroActive: heroActive,
sourceFrame: sf,
fullSize: screenSize,
dragOffset: panCoordinator.dragOffset
))
.tag(index)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.ignoresSafeArea()
.scrollDisabled(currentZoomScale > 1.05 || isDismissing)
.contentShape(Rectangle())
.overlay { galleryOverlay }
.background {
Color.black
.opacity(backgroundOpacity)
Color.black.opacity(backgroundOpacity).ignoresSafeArea()
}
.allowsHitTesting(isExpanded)
.onAppear { viewSize = size }
}
.ignoresSafeArea()
.statusBarHidden(true)
.task {
prefetchAdjacentImages(around: state.initialIndex)
guard !isExpanded else { return }
withAnimation(heroAnimation) {
isExpanded = true
}
}
.onChange(of: currentPage) { _, newPage in
prefetchAdjacentImages(around: newPage)
}
.onChange(of: currentZoomScale) { _, newScale in
panCoordinator.isEnabled = newScale <= 1.05
}
.onChange(of: panCoordinator.panEndSignal) { _, _ in
handlePanEnd()
withAnimation(heroAnimation) { isExpanded = true }
}
.onChange(of: currentPage) { _, p in prefetchAdjacentImages(around: p) }
.onChange(of: currentZoomScale) { _, s in panCoordinator.isEnabled = s <= 1.05 }
.onChange(of: panCoordinator.panEndSignal) { _, _ in handlePanEnd() }
}
// MARK: - Pan End Handler
// MARK: - Pan End
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 {
let y = panCoordinator.dragOffset.height
let v = panCoordinator.endVelocityY
if y > 50 || v > 1000 {
dismissAction()
} else {
withAnimation(heroAnimation.speed(1.2)) {
@@ -292,13 +243,15 @@ struct ImageGalleryViewer: View {
}
}
// MARK: - Gallery Overlay (Telegram parity)
// MARK: - Overlay (Telegram parity)
@ViewBuilder
private var galleryOverlay: some View {
let sa = windowSafeArea
if !isDismissing && isExpanded {
ZStack {
// Top panel slides DOWN from above on show, UP on hide
// Top panel
VStack(spacing: 4) {
topPanel
if state.images.count > 1 {
@@ -307,15 +260,17 @@ struct ImageGalleryViewer: View {
Spacer()
}
.frame(maxWidth: .infinity)
.offset(y: showControls ? 0 : -120)
.padding(.top, sa.top > 0 ? sa.top : 20)
.offset(y: showControls ? 0 : -(sa.top + 120))
.allowsHitTesting(showControls)
// Bottom panel slides UP from below on show, DOWN on hide
// Bottom panel
VStack {
Spacer()
bottomPanel
.padding(.bottom, sa.bottom > 0 ? sa.bottom : 16)
}
.offset(y: showControls ? 0 : 120)
.offset(y: showControls ? 0 : (sa.bottom + 120))
.allowsHitTesting(showControls)
}
.compositingGroup()
@@ -325,15 +280,15 @@ struct ImageGalleryViewer: View {
}
}
// MARK: - Top Panel
// MARK: - Top Panel (Telegram parity)
private var topPanel: some View {
HStack {
HStack(alignment: .top) {
glassCircleButton(systemName: "chevron.left") { dismissAction() }
Spacer(minLength: 0)
glassCircleButton(systemName: "ellipsis") { }
}
.overlay {
.overlay(alignment: .top) {
if let info = currentInfo {
VStack(spacing: 2) {
Text(info.senderName)
@@ -351,11 +306,10 @@ struct ImageGalleryViewer: View {
.animation(.easeInOut, value: currentPage)
}
}
.padding(.horizontal, 15)
.padding(.top, 4)
.padding(.horizontal, 16)
}
// MARK: - Counter Badge (below name capsule)
// MARK: - Counter
private var counterBadge: some View {
Text("\(currentPage + 1) of \(state.images.count)")
@@ -364,32 +318,44 @@ struct ImageGalleryViewer: View {
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background { TelegramGlassCapsule() }
.padding(.top, 6)
.contentTransition(.numericText())
.animation(.easeInOut, value: currentPage)
}
// MARK: - Bottom Panel
// MARK: - Bottom Panel (Telegram parity)
// Telegram: Forward (left) [draw | caption center] Delete (right)
// Rosetta: Forward Share Save Delete
private var bottomPanel: some View {
HStack {
HStack(spacing: 0) {
// Forward
glassCircleButton(systemName: "arrowshape.turn.up.right") { }
Spacer(minLength: 0)
Spacer()
// Share
glassCircleButton(systemName: "square.and.arrow.up") { shareCurrentImage() }
Spacer(minLength: 0)
Spacer()
// Save to Photos
glassCircleButton(systemName: "square.and.arrow.down") { saveCurrentImage() }
Spacer(minLength: 0)
Spacer()
// Delete
glassCircleButton(systemName: "trash") { }
}
.padding(.horizontal, 15)
.padding(.bottom, 8)
.padding(.horizontal, 16)
}
// MARK: - Glass Circle Button
// MARK: - Glass Button
private func glassCircleButton(systemName: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: systemName)
.font(.system(size: 17, weight: .medium))
.font(.system(size: 22))
.foregroundStyle(.white)
.frame(width: 44, height: 44)
}
@@ -399,16 +365,14 @@ struct ImageGalleryViewer: View {
// MARK: - Navigation
private func navigateEdgeTap(direction: Int) {
let target = currentPage + direction
guard target >= 0, target < state.images.count else { return }
currentPage = target
let t = currentPage + direction
guard t >= 0, t < state.images.count else { return }
currentPage = t
}
// MARK: - Dismiss
private func dismissAction() {
// 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 {
@@ -420,7 +384,6 @@ struct ImageGalleryViewer: View {
guard !isDismissing else { return }
isDismissing = true
panCoordinator.isEnabled = false
Task {
withAnimation(heroAnimation.speed(1.2)) {
panCoordinator.dragOffset = .zero
@@ -435,16 +398,10 @@ struct ImageGalleryViewer: View {
guard !isDismissing else { return }
isDismissing = true
panCoordinator.isEnabled = 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.27) {
onDismiss()
panCoordinator.dragOffset = CGSize(width: 0, height: screenSize.height * 0.4)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.27) { onDismiss() }
}
// MARK: - Actions
@@ -453,20 +410,14 @@ struct ImageGalleryViewer: View {
guard let info = currentInfo,
let image = AttachmentCache.shared.loadImage(forAttachmentId: info.attachmentId)
else { return }
let activityVC = UIActivityViewController(activityItems: [image], applicationActivities: nil)
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let root = windowScene.keyWindow?.rootViewController {
var presenter = root
while let presented = presenter.presentedViewController {
presenter = presented
}
activityVC.popoverPresentationController?.sourceView = presenter.view
activityVC.popoverPresentationController?.sourceRect = CGRect(
x: presenter.view.bounds.midX, y: presenter.view.bounds.maxY - 50,
width: 0, height: 0
)
presenter.present(activityVC, animated: true)
let vc = UIActivityViewController(activityItems: [image], applicationActivities: nil)
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let root = scene.keyWindow?.rootViewController {
var p = root; while let pp = p.presentedViewController { p = pp }
vc.popoverPresentationController?.sourceView = p.view
vc.popoverPresentationController?.sourceRect = CGRect(
x: p.view.bounds.midX, y: p.view.bounds.maxY - 50, width: 0, height: 0)
p.present(vc, animated: true)
}
}
@@ -474,7 +425,6 @@ struct ImageGalleryViewer: View {
guard let info = currentInfo,
let image = AttachmentCache.shared.loadImage(forAttachmentId: info.attachmentId)
else { return }
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
guard status == .authorized || status == .limited else { return }
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
@@ -487,13 +437,39 @@ struct ImageGalleryViewer: View {
for offset in [-2, -1, 1, 2] {
let i = index + offset
guard i >= 0, i < state.images.count else { continue }
let attachmentId = state.images[i].attachmentId
guard AttachmentCache.shared.cachedImage(forAttachmentId: attachmentId) == nil else { continue }
let aid = state.images[i].attachmentId
guard AttachmentCache.shared.cachedImage(forAttachmentId: aid) == nil else { continue }
Task.detached(priority: .utility) {
await ImageLoadLimiter.shared.acquire()
_ = AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
_ = AttachmentCache.shared.loadImage(forAttachmentId: aid)
await ImageLoadLimiter.shared.release()
}
}
}
}
// MARK: - GalleryPageModifier
/// Applies hero transition frame/offset ONLY for the initial page.
/// Non-hero pages have NO explicit frame they fill the TabView page naturally,
/// which fixes the "tiny image" bug caused by explicit frame fighting with TabView sizing.
private struct GalleryPageModifier: ViewModifier {
let heroActive: Bool
let sourceFrame: CGRect
let fullSize: CGSize
let dragOffset: CGSize
func body(content: Content) -> some View {
if heroActive {
content
.frame(width: sourceFrame.width, height: sourceFrame.height)
.clipped()
.offset(x: sourceFrame.minX, y: sourceFrame.minY)
.offset(dragOffset)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
} else {
content
.offset(dragOffset)
}
}
}

View File

@@ -15,4 +15,5 @@ final class MessageCellActions {
var onRemove: (ChatMessage) -> Void = { _ in }
var onCall: (String) -> Void = { _ in } // peer public key
var onGroupInviteTap: (String) -> Void = { _ in } // invite string
var onGroupInviteOpen: (String) -> Void = { _ in } // group dialog key navigate
}

View File

@@ -59,7 +59,8 @@ struct MessageCellView: View, Equatable {
}
.modifier(ConditionalSwipeToReply(
enabled: !isSavedMessages && !isSystemAccount
&& !message.attachments.contains(where: { $0.type == .avatar }),
&& !message.attachments.contains(where: { $0.type == .avatar })
&& !message.text.hasPrefix("#group:"),
onReply: { actions.onReply(message) }
))
.overlay {
@@ -96,6 +97,32 @@ struct MessageCellView: View, Equatable {
message: message, reply: reply, outgoing: outgoing,
hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position
)
} else if message.text.hasPrefix("#group:"),
let parsed = GroupRepository.parseInviteStringPure(message.text) {
GroupInviteCardView(
inviteString: message.text,
title: parsed.title,
groupId: parsed.groupId,
isOutgoing: outgoing,
actions: actions
)
.frame(width: min(220, maxBubbleWidth))
.overlay(alignment: .bottomTrailing) {
timestampOverlay(message: message, outgoing: outgoing)
}
.padding(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
.padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
.background { bubbleBackground(outgoing: outgoing, position: position) }
.overlay {
BubbleContextMenuOverlay(
items: contextMenuItems(for: message),
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
isOutgoing: outgoing,
replyQuoteHeight: 0,
onReplyQuoteTap: nil
)
}
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
} else {
VStack(alignment: .leading, spacing: 0) {
if let reply = replyData {

View File

@@ -183,6 +183,21 @@ final class NativeMessageCell: UICollectionViewCell {
private let senderAvatarImageView = UIImageView()
private let senderAvatarInitialLabel = UILabel()
// Group Invite Card
private let groupInviteContainer = UIView()
private let groupInviteIconBg = UIView()
private let groupInviteIcon = UIImageView()
private let groupInviteTitleLabel = UILabel()
private let groupInviteStatusLabel = UILabel()
private let groupInviteButton = UIButton(type: .custom)
private var groupInviteString: String?
private var currentInviteStatus: InviteCardStatus = .notJoined
private var inviteStatusTask: Task<Void, Never>?
enum InviteCardStatus {
case notJoined, joined, invalid, banned
}
// Highlight overlay (scroll-to-message flash)
private let highlightOverlay = UIView()
@@ -434,6 +449,35 @@ final class NativeMessageCell: UICollectionViewCell {
bubbleView.addSubview(fileContainer)
// Group Invite Card
groupInviteIconBg.layer.cornerRadius = 22
groupInviteIconBg.clipsToBounds = true
groupInviteIcon.image = UIImage(
systemName: "person.2.fill",
withConfiguration: UIImage.SymbolConfiguration(pointSize: 18, weight: .medium)
)
groupInviteIcon.tintColor = .white
groupInviteIcon.contentMode = .scaleAspectFit
groupInviteIconBg.addSubview(groupInviteIcon)
groupInviteContainer.addSubview(groupInviteIconBg)
groupInviteTitleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
groupInviteTitleLabel.lineBreakMode = .byTruncatingTail
groupInviteContainer.addSubview(groupInviteTitleLabel)
groupInviteStatusLabel.font = .systemFont(ofSize: 12, weight: .regular)
groupInviteContainer.addSubview(groupInviteStatusLabel)
groupInviteButton.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium)
groupInviteButton.layer.cornerRadius = 14
groupInviteButton.clipsToBounds = true
groupInviteButton.contentEdgeInsets = UIEdgeInsets(top: 5, left: 14, bottom: 5, right: 14)
groupInviteButton.addTarget(self, action: #selector(groupInviteCardTapped), for: .touchUpInside)
groupInviteContainer.addSubview(groupInviteButton)
groupInviteContainer.isHidden = true
bubbleView.addSubview(groupInviteContainer)
// Listen for avatar download trigger (tap-to-download, Android parity)
NotificationCenter.default.addObserver(
self, selector: #selector(handleAttachmentDownload(_:)),
@@ -822,6 +866,89 @@ final class NativeMessageCell: UICollectionViewCell {
} else {
fileContainer.isHidden = true
}
// Group Invite Card
if let layout = currentLayout, layout.hasGroupInvite {
groupInviteContainer.isHidden = false
textLabel.isHidden = true
groupInviteString = message.text
groupInviteTitleLabel.text = layout.groupInviteTitle.isEmpty ? "Group" : layout.groupInviteTitle
// Local membership check (fast, MainActor)
let isJoined = GroupRepository.shared.hasGroup(for: layout.groupInviteGroupId)
applyGroupInviteStatus(isJoined ? .joined : .notJoined, isOutgoing: layout.isOutgoing)
// Async server check for authoritative status
let msgId = message.id, groupId = layout.groupInviteGroupId
inviteStatusTask?.cancel()
inviteStatusTask = Task { @MainActor [weak self] in
guard !Task.isCancelled else { return }
guard let self, self.message?.id == msgId else { return }
if let (_, status) = try? await GroupService.shared.checkInviteStatus(groupId: groupId),
!Task.isCancelled,
self.message?.id == msgId {
let cardStatus: InviteCardStatus = switch status {
case .joined: .joined
case .notJoined: .notJoined
case .invalid: .invalid
case .banned: .banned
}
self.applyGroupInviteStatus(cardStatus, isOutgoing: layout.isOutgoing)
}
}
} else {
groupInviteContainer.isHidden = true
}
}
private func applyGroupInviteStatus(_ status: InviteCardStatus, isOutgoing: Bool) {
currentInviteStatus = status
let color: UIColor
switch status {
case .notJoined: color = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 1) // #248AE6
case .joined: color = UIColor(red: 0.20, green: 0.78, blue: 0.35, alpha: 1) // #34C759
case .invalid, .banned: color = UIColor(red: 0.98, green: 0.23, blue: 0.19, alpha: 1)
}
groupInviteIconBg.backgroundColor = color
groupInviteTitleLabel.textColor = isOutgoing ? .white : color
let statusText: String
switch status {
case .notJoined: statusText = "Invite to join this group"
case .joined: statusText = "You are a member"
case .invalid: statusText = "This invite is invalid"
case .banned: statusText = "You are banned"
}
groupInviteStatusLabel.text = statusText
groupInviteStatusLabel.textColor = isOutgoing
? UIColor.white.withAlphaComponent(0.7)
: .secondaryLabel
let buttonTitle: String
switch status {
case .notJoined: buttonTitle = "Join Group"
case .joined: buttonTitle = "Open Group"
case .invalid: buttonTitle = "Invalid"
case .banned: buttonTitle = "Banned"
}
groupInviteButton.setTitle(buttonTitle, for: .normal)
groupInviteButton.setTitleColor(.white, for: .normal)
groupInviteButton.backgroundColor = color
groupInviteButton.isEnabled = (status == .notJoined || status == .joined)
groupInviteButton.alpha = groupInviteButton.isEnabled ? 1.0 : 0.5
}
@objc private func groupInviteCardTapped() {
guard let inviteStr = groupInviteString, let actions else { return }
if currentInviteStatus == .joined {
if let parsed = GroupRepository.parseInviteStringPure(inviteStr) {
let dialogKey = "#group:\(parsed.groupId)"
actions.onGroupInviteOpen(dialogKey)
}
} else if currentInviteStatus == .notJoined {
actions.onGroupInviteTap(inviteStr)
}
}
/// Apply pre-calculated layout (main thread only just sets frames).
@@ -1009,6 +1136,23 @@ final class NativeMessageCell: UICollectionViewCell {
}
}
// Group Invite Card
groupInviteContainer.isHidden = !layout.hasGroupInvite
if layout.hasGroupInvite {
groupInviteContainer.frame = CGRect(x: 0, y: 0, width: layout.bubbleSize.width, height: layout.bubbleSize.height)
let cW = layout.bubbleSize.width
let topY: CGFloat = 10
groupInviteIconBg.frame = CGRect(x: 10, y: topY, width: 44, height: 44)
groupInviteIcon.frame = CGRect(x: 12, y: 12, width: 20, height: 20)
let textX: CGFloat = 64
let textW = cW - textX - 10
groupInviteTitleLabel.frame = CGRect(x: textX, y: topY + 2, width: textW, height: 19)
groupInviteStatusLabel.frame = CGRect(x: textX, y: topY + 22, width: textW, height: 16)
let btnSize = groupInviteButton.sizeThatFits(CGSize(width: 200, height: 28))
let btnW = min(btnSize.width + 28, textW)
groupInviteButton.frame = CGRect(x: textX, y: topY + 42, width: btnW, height: 28)
}
// Forward
if layout.isForward {
forwardLabel.frame = layout.forwardHeaderFrame
@@ -1100,6 +1244,9 @@ final class NativeMessageCell: UICollectionViewCell {
if layout.hasFile {
fileContainer.frame.origin.y += senderNameShift
}
if layout.hasGroupInvite {
groupInviteContainer.frame.origin.y += senderNameShift
}
if layout.isForward {
forwardLabel.frame.origin.y += senderNameShift
forwardAvatarView.frame.origin.y += senderNameShift
@@ -1354,7 +1501,8 @@ final class NativeMessageCell: UICollectionViewCell {
@objc private func handleSwipe(_ gesture: UIPanGestureRecognizer) {
if isSavedMessages || isSystemAccount { return }
let isReplyBlocked = message?.attachments.contains(where: { $0.type == .avatar }) ?? false
let isReplyBlocked = (message?.attachments.contains(where: { $0.type == .avatar }) ?? false)
|| (currentLayout?.messageType == .groupInvite)
if isReplyBlocked { return }
let translation = gesture.translation(in: contentView)
@@ -2374,6 +2522,11 @@ final class NativeMessageCell: UICollectionViewCell {
fileContainer.isHidden = true
callArrowView.isHidden = true
callBackButton.isHidden = true
groupInviteContainer.isHidden = true
groupInviteString = nil
currentInviteStatus = .notJoined
inviteStatusTask?.cancel()
inviteStatusTask = nil
avatarImageView.image = nil
avatarImageView.isHidden = true
fileIconView.isHidden = false

View File

@@ -125,6 +125,7 @@ final class NativeMessageListController: UIViewController {
private var datePillPool: [(container: UIView, label: UILabel)] = []
private var dateHideTimer: Timer?
private var areDatePillsVisible = false
private var animatePillFrames = false
// MARK: - Empty State (UIKit-managed, animates with keyboard)
private var emptyStateHosting: UIHostingController<EmptyChatContent>?
@@ -693,10 +694,20 @@ final class NativeMessageListController: UIViewController {
let sections = sectionMap.map { DateSection(text: $0.key, topY: $0.value.topY, bottomY: $0.value.bottomY) }
.sorted { $0.topY < $1.topY }
// 2. Position each section's pill using Telegram's formula.
// 2. Expand pool if more sections than pills (short chats spanning many days).
while datePillPool.count < sections.count {
let pill = makeDatePill()
if let composer = composerView {
view.insertSubview(pill.container, belowSubview: composer)
} else {
view.addSubview(pill.container)
}
datePillPool.append(pill)
}
// 3. Position each section's pill using Telegram's formula.
var usedPillCount = 0
for section in sections {
guard usedPillCount < datePillPool.count else { break }
// Telegram formula: headerY = min(max(sectionTop, stickyY), sectionBottom - pillH)
// +9 = vertically centered in 42pt dateHeaderHeight: (42 - 24) / 2 = 9
@@ -711,7 +722,7 @@ final class NativeMessageListController: UIViewController {
let pill = datePillPool[usedPillCount]
// Prevent resize animation when reusing pool pill with different text.
// Phase 1: Internal properties always snap (no animation).
CATransaction.begin()
CATransaction.setDisableActions(true)
pill.label.text = section.text
@@ -723,20 +734,34 @@ final class NativeMessageListController: UIViewController {
x: round((screenW - pillW) / 2), y: headerY,
width: pillW, height: pillH
)
pill.container.frame = pillFrame
pill.container.layer.cornerRadius = pillH / 2
pill.label.frame = pill.container.bounds
// Explicitly set glass frame (no autoresizingMask prevents 1-frame inflation).
if let glass = pill.container.subviews.first(where: { $0.tag == 42 }) {
glass.frame = pill.container.bounds
glass.layoutIfNeeded()
}
pill.container.isHidden = false
// Natural-position pills always visible. Stuck pills fade with timer.
pill.container.alpha = isStuck ? (areDatePillsVisible ? 1 : 0) : 1
pill.container.tag = isStuck ? 1 : 0
CATransaction.commit()
// Phase 2: Container frame animate during keyboard, snap otherwise.
if animatePillFrames {
// Inside UIView.animate block implicit animation matches keyboard curve.
pill.container.frame = pillFrame
} else {
CATransaction.begin()
CATransaction.setDisableActions(true)
pill.container.frame = pillFrame
CATransaction.commit()
}
// Phase 3: Sync child frames to container (always snap).
CATransaction.begin()
CATransaction.setDisableActions(true)
pill.label.frame = pill.container.bounds
if let glass = pill.container.subviews.first(where: { $0.tag == 42 }) {
glass.frame = pill.container.bounds
glass.layoutIfNeeded()
}
CATransaction.commit()
usedPillCount += 1
}
@@ -867,6 +892,10 @@ final class NativeMessageListController: UIViewController {
// Capture visible cell positions BEFORE applying snapshot (for position animation)
var oldPositions: [String: CGFloat] = [:]
// Capture pill positions for matching spring animation
let oldPillPositions = isInteractive
? datePillPool.map { (y: $0.container.layer.position.y, visible: !$0.container.isHidden) }
: []
if isInteractive {
for ip in collectionView.indexPathsForVisibleItems {
if let cellId = dataSource.itemIdentifier(for: ip),
@@ -901,6 +930,30 @@ final class NativeMessageListController: UIViewController {
if isInteractive {
collectionView.layoutIfNeeded()
applyInsertionAnimations(newIds: newIds, oldPositions: oldPositions)
// Animate date pills with same spring as cells
updateFloatingDateHeader()
for (i, pill) in datePillPool.enumerated() {
guard i < oldPillPositions.count,
!pill.container.isHidden,
oldPillPositions[i].visible else { continue }
let dy = oldPillPositions[i].y - pill.container.layer.position.y
guard abs(dy) > 0.5 else { continue }
let move = CASpringAnimation(keyPath: "position.y")
move.fromValue = dy
move.toValue = 0.0
move.isAdditive = true
move.stiffness = 555.0
move.damping = 47.0
move.mass = 1.0
move.initialVelocity = 0
move.duration = move.settlingDuration
move.fillMode = .backwards
pill.container.layer.add(move, forKey: "pillInsertionMove")
}
} else {
updateFloatingDateHeader()
}
if !hasCompletedInitialLoad && !messages.isEmpty {
@@ -1186,8 +1239,15 @@ final class NativeMessageListController: UIViewController {
}
self.view.layoutIfNeeded()
// Position pills at FINAL positions implicit animation from
// UIView.animate matches keyboard curve/duration automatically.
self.animatePillFrames = true
self.updateFloatingDateHeader()
self.animatePillFrames = false
}, completion: { _ in
self.isKeyboardAnimating = false
self.updateFloatingDateHeader()
})
}
@@ -1224,7 +1284,9 @@ final class NativeMessageListController: UIViewController {
extension NativeMessageListController: UICollectionViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if !isKeyboardAnimating {
updateFloatingDateHeader()
}
let offsetFromBottom = scrollView.contentOffset.y + scrollView.contentInset.top
let isAtBottom = offsetFromBottom < 50

View File

@@ -33,13 +33,25 @@ enum TelegramContextMenuBuilder {
))
}
// Desktop parity: detect #group: invite strings and offer "Join Group" action.
// Desktop parity: detect #group: invite strings and offer "Join/Open Group" action.
if message.text.hasPrefix("#group:") {
let isJoined: Bool
if let parsed = GroupRepository.parseInviteStringPure(message.text) {
isJoined = GroupRepository.shared.hasGroup(for: parsed.groupId)
} else {
isJoined = false
}
items.append(TelegramContextMenuItem(
title: "Join Group",
iconName: "person.2.badge.plus",
title: isJoined ? "Open Group" : "Join Group",
iconName: isJoined ? "person.2" : "person.2.badge.plus",
isDestructive: false,
handler: { actions.onGroupInviteTap(message.text) }
handler: {
if isJoined, let parsed = GroupRepository.parseInviteStringPure(message.text) {
actions.onGroupInviteOpen("#group:\(parsed.groupId)")
} else {
actions.onGroupInviteTap(message.text)
}
}
))
}

View File

@@ -3,9 +3,9 @@ import UIKit
// MARK: - ZoomableImagePage
/// Single page in the image gallery viewer with UIKit-based gesture handling.
/// Android parity: `ZoomableImage` in `ImageViewerScreen.kt` centroid-based pinch zoom,
/// double-tap to tap point, velocity-based dismiss, axis locking, edge tap navigation.
/// Single page in the image gallery viewer.
/// Uses GeometryReader + explicit frame calculation (Telegram parity) instead of
/// `.scaledToFit()` which is unreliable inside TabView `.page` style overlay chains.
struct ZoomableImagePage: View {
let attachmentId: String
@@ -23,28 +23,27 @@ struct ZoomableImagePage: View {
var body: some View {
let effectiveScale = zoomScale * pinchScale
// Color.clear always fills ALL proposed space from the parent TabView page,
// hero frame, etc. The Image in .overlay sizes relative to Color.clear's actual
// rendered frame. Previous approach (.scaledToFit + .frame(maxWidth: .infinity))
// sometimes got a stale/zero proposed size from TabView lazy page creation,
// causing the image to render at thumbnail size.
Color.clear
.overlay {
GeometryReader { geo in
let viewSize = geo.size
if let image {
let fitted = fittedSize(image.size, in: viewSize)
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(width: fitted.width, height: fitted.height)
.scaleEffect(effectiveScale)
.offset(
x: effectiveScale > 1.05 ? zoomOffset.width : 0,
y: effectiveScale > 1.05 ? zoomOffset.height : 0
)
.position(x: viewSize.width / 2, y: viewSize.height / 2)
} else {
placeholder
.position(x: viewSize.width / 2, y: viewSize.height / 2)
}
}
.contentShape(Rectangle())
// Double tap: zoom to 2.5x or reset (MUST be before single tap)
.onTapGesture(count: 2) {
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
if zoomScale > 1.1 {
@@ -56,7 +55,6 @@ struct ZoomableImagePage: View {
currentScale = zoomScale
}
}
// Single tap: toggle controls / edge navigation
.onTapGesture { location in
let width = UIScreen.main.bounds.width
let edgeZone = width * 0.20
@@ -68,7 +66,6 @@ struct ZoomableImagePage: View {
showControls.toggle()
}
}
// Pinch zoom
.simultaneousGesture(
MagnifyGesture()
.updating($pinchScale) { value, state, _ in
@@ -86,16 +83,11 @@ struct ZoomableImagePage: View {
}
}
)
// Pan when zoomed
.simultaneousGesture(
zoomScale > 1.05 ?
DragGesture()
.onChanged { value in
zoomOffset = value.translation
}
.onEnded { _ in
// Clamp offset
}
.onChanged { value in zoomOffset = value.translation }
.onEnded { _ in }
: nil
)
.task {
@@ -114,6 +106,21 @@ struct ZoomableImagePage: View {
}
}
// MARK: - Aspect-fit calculation (Telegram parity)
/// Calculates the image frame to fill the view while maintaining aspect ratio.
/// Telegram: `contentSize.fitted(boundsSize)` in ZoomableContentGalleryItemNode.
private func fittedSize(_ imageSize: CGSize, in viewSize: CGSize) -> CGSize {
guard imageSize.width > 0, imageSize.height > 0,
viewSize.width > 0, viewSize.height > 0 else {
return viewSize
}
let scale = min(viewSize.width / imageSize.width,
viewSize.height / imageSize.height)
return CGSize(width: imageSize.width * scale,
height: imageSize.height * scale)
}
// MARK: - Placeholder
private var placeholder: some View {
@@ -126,4 +133,3 @@ struct ZoomableImagePage: View {
}
}
}

View File

@@ -135,10 +135,19 @@ struct ChatListView: View {
}
.onReceive(NotificationCenter.default.publisher(for: .openChatFromNotification)) { notification in
guard let route = notification.object as? ChatRoute else { return }
// Navigate to the chat from push notification tap (fast path)
navigationState.path = [route]
AppDelegate.pendingChatRoute = nil
AppDelegate.pendingChatRouteTimestamp = nil
// If already inside a chat, pop first then push after animation.
// Direct path replacement reuses the same ChatDetailView (SwiftUI optimization),
// which only updates the toolbar but keeps the old messages.
if !navigationState.path.isEmpty {
navigationState.path = []
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
navigationState.path = [route]
}
} else {
navigationState.path = [route]
}
}
.onAppear {
// Cold start fallback: ChatListView didn't exist when notification was posted.