Минимизированный call-бар: UIKit additionalSafeAreaInsets для сдвига навбара, Telegram-style градиент и UI-рефакторинг

This commit is contained in:
2026-03-30 04:24:48 +05:00
parent 2b25c87a6a
commit f24f7ee555
20 changed files with 1444 additions and 439 deletions

View File

@@ -1,28 +1,15 @@
import SwiftUI
import UIKit
/// Action model for context menu buttons.
struct BubbleContextAction {
let title: String
let image: UIImage?
let role: UIMenuElement.Attributes
let handler: () -> Void
}
/// Transparent overlay that attaches UIContextMenuInteraction to a message bubble.
/// Transparent overlay that triggers a Telegram-style context menu on long press.
///
/// Uses a **window snapshot** approach instead of UIHostingController preview:
/// 1. On long-press, captures a pixel-perfect screenshot of the bubble from the window
/// 2. Uses this snapshot as `UITargetedPreview` with `previewProvider: nil`
/// 3. UIKit lifts the snapshot in-place no horizontal shift, no re-rendering issues
///
/// Also supports an optional `onTap` callback that fires on single tap.
/// This is needed because the overlay UIView intercepts all touch events,
/// preventing SwiftUI `onTapGesture` on content below from firing.
/// Also supports single-tap routing (image viewer, file download, reply quote tap)
/// because the overlay UIView intercepts all touch events, preventing SwiftUI
/// `onTapGesture` on content below from firing.
struct BubbleContextMenuOverlay: UIViewRepresentable {
let actions: [BubbleContextAction]
let items: [TelegramContextMenuItem]
let previewShape: MessageBubbleShape
let readStatusText: String?
let isOutgoing: Bool
/// Called when user single-taps the bubble. Receives tap location in the overlay's
/// coordinate space (for determining which sub-element was tapped, e.g., which photo in a collage).
@@ -38,48 +25,54 @@ struct BubbleContextMenuOverlay: UIViewRepresentable {
func makeUIView(context: Context) -> UIView {
let view = UIView()
view.backgroundColor = .clear
let interaction = UIContextMenuInteraction(delegate: context.coordinator)
view.addInteraction(interaction)
// Single tap recognizer coexists with context menu's long press.
// Single tap recognizer coexists with long press.
// ALL taps go through this (overlay UIView blocks SwiftUI gestures below).
// onTap handler in ChatDetailView routes to image viewer, file share,
// or posts .triggerAttachmentDownload notification for downloads.
let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:)))
view.addGestureRecognizer(tap)
// Long press Telegram context menu
let longPress = UILongPressGestureRecognizer(
target: context.coordinator,
action: #selector(Coordinator.handleLongPress(_:))
)
longPress.minimumPressDuration = 0.35
view.addGestureRecognizer(longPress)
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
// PERF: only update callbacks (lightweight pointer swap).
// Skip actions/previewShape/readStatusText these involve array allocation
// and struct copying on EVERY layout pass (40× cells × 8 keyboard ticks = 320/s).
// Context menu will use stale actions until cell is recycled acceptable trade-off.
context.coordinator.items = items
context.coordinator.previewShape = previewShape
context.coordinator.isOutgoing = isOutgoing
context.coordinator.onTap = onTap
context.coordinator.replyQuoteHeight = replyQuoteHeight
context.coordinator.onReplyQuoteTap = onReplyQuoteTap
}
func makeCoordinator() -> Coordinator { Coordinator(overlay: self) }
final class Coordinator: NSObject, UIContextMenuInteractionDelegate {
var actions: [BubbleContextAction]
final class Coordinator: NSObject {
var items: [TelegramContextMenuItem]
var previewShape: MessageBubbleShape
var readStatusText: String?
var isOutgoing: Bool
var onTap: ((CGPoint) -> Void)?
var replyQuoteHeight: CGFloat = 0
var onReplyQuoteTap: (() -> Void)?
private var snapshotView: UIImageView?
private let haptic = UIImpactFeedbackGenerator(style: .medium)
init(overlay: BubbleContextMenuOverlay) {
self.actions = overlay.actions
self.items = overlay.items
self.previewShape = overlay.previewShape
self.readStatusText = overlay.readStatusText
self.isOutgoing = overlay.isOutgoing
self.onTap = overlay.onTap
self.replyQuoteHeight = overlay.replyQuoteHeight
self.onReplyQuoteTap = overlay.onReplyQuoteTap
}
// MARK: - Single Tap
@objc func handleTap(_ recognizer: UITapGestureRecognizer) {
// Route taps in the reply quote region to the reply handler.
if replyQuoteHeight > 0, let view = recognizer.view {
@@ -93,100 +86,32 @@ struct BubbleContextMenuOverlay: UIViewRepresentable {
onTap?(location)
}
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
configurationForMenuAtLocation location: CGPoint
) -> UIContextMenuConfiguration? {
captureSnapshot(for: interaction)
// MARK: - Long Press Context Menu
return UIContextMenuConfiguration(
identifier: nil,
previewProvider: nil,
actionProvider: { [weak self] _ in
self?.buildMenu()
}
@objc func handleLongPress(_ recognizer: UILongPressGestureRecognizer) {
guard recognizer.state == .began else { return }
haptic.impactOccurred()
presentMenu(from: recognizer.view)
}
private func presentMenu(from view: UIView?) {
guard let view else { return }
guard !items.isEmpty else { return }
// Capture snapshot from window
guard let (snapshot, frame) = TelegramContextMenuController.captureSnapshot(of: view) else { return }
// Build bubble mask path
let shapePath = previewShape.path(in: CGRect(origin: .zero, size: frame.size))
let bubblePath = UIBezierPath(cgPath: shapePath.cgPath)
TelegramContextMenuController.present(
snapshot: snapshot,
sourceFrame: frame,
bubblePath: bubblePath,
items: items,
isOutgoing: isOutgoing
)
}
// MARK: - Snapshot
private func captureSnapshot(for interaction: UIContextMenuInteraction) {
guard let view = interaction.view, let window = view.window else { return }
let frameInWindow = view.convert(view.bounds, to: window)
let renderer = UIGraphicsImageRenderer(size: view.bounds.size)
let image = renderer.image { ctx in
ctx.cgContext.translateBy(x: -frameInWindow.origin.x, y: -frameInWindow.origin.y)
window.drawHierarchy(in: window.bounds, afterScreenUpdates: false)
}
let sv = UIImageView(image: image)
sv.frame = view.bounds
view.addSubview(sv)
self.snapshotView = sv
}
// MARK: - Menu
private func buildMenu() -> UIMenu {
var sections: [UIMenuElement] = []
if let readStatus = readStatusText {
let readAction = UIAction(
title: readStatus,
image: UIImage(systemName: "checkmark"),
attributes: .disabled
) { _ in }
sections.append(UIMenu(options: .displayInline, children: [readAction]))
}
let menuActions = actions.map { action in
UIAction(
title: action.title,
image: action.image,
attributes: action.role
) { _ in
action.handler()
}
}
sections.append(UIMenu(options: .displayInline, children: menuActions))
return UIMenu(children: sections)
}
// MARK: - Targeted Preview (lift & dismiss)
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration
) -> UITargetedPreview? {
guard let sv = snapshotView else { return nil }
return makeTargetedPreview(for: sv)
}
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
previewForDismissingMenuWithConfiguration configuration: UIContextMenuConfiguration
) -> UITargetedPreview? {
guard let sv = snapshotView else { return nil }
return makeTargetedPreview(for: sv)
}
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
willEndFor configuration: UIContextMenuConfiguration,
animator: (any UIContextMenuInteractionAnimating)?
) {
animator?.addCompletion { [weak self] in
self?.snapshotView?.removeFromSuperview()
self?.snapshotView = nil
}
}
private func makeTargetedPreview(for view: UIView) -> UITargetedPreview {
let params = UIPreviewParameters()
let shapePath = previewShape.path(in: view.bounds)
params.visiblePath = UIBezierPath(cgPath: shapePath.cgPath)
params.backgroundColor = .clear
return UITargetedPreview(view: view, parameters: params)
}
}
}

View File

@@ -1167,58 +1167,51 @@ private extension ChatDetailView {
// MARK: - Reply Bar
/// Extract reply preview text for ComposerView (same logic as SwiftUI replyBar).
///
/// Priority: attachment type label first, then clean caption text.
/// Previous version checked `message.text` BEFORE attachment type which caused empty
/// previews when text contained invisible/encrypted characters for photo/file messages.
func replyPreviewText(for message: ChatMessage) -> String {
if message.attachments.contains(where: { $0.type == .image }) {
let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
return caption.isEmpty ? "Photo" : caption
}
if let file = message.attachments.first(where: { $0.type == .file }) {
let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
if !caption.isEmpty { return caption }
let parsed = AttachmentPreviewCodec.parseFilePreview(file.preview)
if !parsed.fileName.isEmpty { return parsed.fileName }
return file.id.isEmpty ? "File" : file.id
}
if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" }
if message.attachments.contains(where: { $0.type == .messages }) { return "Forwarded message" }
// Android/Desktop parity: show "Call" for call attachments
if message.attachments.contains(where: { $0.type == .call }) { return "Call" }
let trimmed = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return message.text }
if !message.attachments.isEmpty { return "Attachment" }
// 1. Determine attachment type label
let attachmentLabel: String? = {
for att in message.attachments {
switch att.type {
case .image: return "Photo"
case .file:
let parsed = AttachmentPreviewCodec.parseFilePreview(att.preview)
if !parsed.fileName.isEmpty { return parsed.fileName }
return att.id.isEmpty ? "File" : att.id
case .avatar: return "Avatar"
case .messages: return "Forwarded message"
case .call: return "Call"
}
}
return nil
}()
// 2. Clean caption strip invisible chars (zero-width spaces, encrypted residue)
let visibleText: String = {
let stripped = message.text
.trimmingCharacters(in: .whitespacesAndNewlines)
.filter { !$0.isASCII || $0.asciiValue! >= 0x20 } // drop control chars
// Extra guard: if text looks like encrypted payload, ignore it
if MessageCellLayout.isGarbageOrEncrypted(stripped) { return "" }
return stripped
}()
// 3. For image/file with non-empty caption: show caption
if attachmentLabel != nil, !visibleText.isEmpty { return visibleText }
// 4. For image/file with no caption: show type label
if let label = attachmentLabel { return label }
// 5. No attachment: show text
if !visibleText.isEmpty { return message.text }
return ""
}
@ViewBuilder
func replyBar(for message: ChatMessage) -> some View {
let senderName = senderDisplayName(for: message.fromPublicKey)
let previewText: String = {
// Attachment type labels check BEFORE text so photo/avatar messages
// always show their type even if text contains invisible characters.
if message.attachments.contains(where: { $0.type == .image }) {
let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
return caption.isEmpty ? "Photo" : caption
}
if let file = message.attachments.first(where: { $0.type == .file }) {
let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
if !caption.isEmpty { return caption }
let parsed = AttachmentPreviewCodec.parseFilePreview(file.preview)
if !parsed.fileName.isEmpty { return parsed.fileName }
return file.id.isEmpty ? "File" : file.id
}
if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" }
if message.attachments.contains(where: { $0.type == .messages }) { return "Forwarded message" }
// Android/Desktop parity: show "Call" for call attachments
if message.attachments.contains(where: { $0.type == .call }) { return "Call" }
// No known attachment type fall back to text
let trimmed = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return message.text }
if !message.attachments.isEmpty { return "Attachment" }
return ""
}()
#if DEBUG
let _ = print("📋 REPLY: preview='\(previewText.prefix(30))' text='\(message.text.prefix(30))' textHex=\(Array(message.text.utf8).prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")) atts=\(message.attachments.count) types=\(message.attachments.map { $0.type.rawValue })")
#endif
let previewText = replyPreviewText(for: message)
HStack(spacing: 0) {
RoundedRectangle(cornerRadius: 1.0)

View File

@@ -235,12 +235,18 @@ final class ComposerView: UIView, UITextViewDelegate {
func setReply(senderName: String?, previewText: String?) {
let shouldShow = senderName != nil
// Always update label text when reply is active (fixes race where
// syncComposerState runs before SwiftUI body re-evaluates with new state)
if shouldShow {
replySenderLabel.text = senderName
replyPreviewLabel.text = previewText ?? ""
}
guard shouldShow != isReplyVisible else { return }
isReplyVisible = shouldShow
if shouldShow {
replySenderLabel.text = senderName
replyPreviewLabel.text = previewText ?? ""
replyBar.isHidden = false
}

View File

@@ -122,9 +122,9 @@ struct MessageCellView: View, Equatable {
.background { bubbleBackground(outgoing: outgoing, position: position) }
.overlay {
BubbleContextMenuOverlay(
actions: bubbleActions(for: message),
items: contextMenuItems(for: message),
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
readStatusText: contextMenuReadStatus(for: message),
isOutgoing: outgoing,
replyQuoteHeight: replyData != nil ? 46 : 0,
onReplyQuoteTap: replyData.map { reply in
{ [reply] in actions.onScrollToMessage(reply.message_id) }
@@ -262,9 +262,9 @@ struct MessageCellView: View, Equatable {
.background { bubbleBackground(outgoing: outgoing, position: position) }
.overlay {
BubbleContextMenuOverlay(
actions: bubbleActions(for: message),
items: contextMenuItems(for: message),
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
readStatusText: contextMenuReadStatus(for: message),
isOutgoing: outgoing,
onTap: !imageAttachments.isEmpty ? { _ in
if let firstId = imageAttachments.first?.id {
actions.onImageTap(firstId)
@@ -362,9 +362,9 @@ struct MessageCellView: View, Equatable {
.clipShape(MessageBubbleShape(position: position, outgoing: outgoing))
.overlay {
BubbleContextMenuOverlay(
actions: bubbleActions(for: message),
items: contextMenuItems(for: message),
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
readStatusText: contextMenuReadStatus(for: message),
isOutgoing: outgoing,
onTap: !attachments.isEmpty ? { tapLocation in
if !imageAttachments.isEmpty {
let tappedId = imageAttachments.count == 1
@@ -536,48 +536,13 @@ struct MessageCellView: View, Equatable {
// MARK: - Context Menu
private func contextMenuReadStatus(for message: ChatMessage) -> String? {
let outgoing = message.isFromMe(myPublicKey: currentPublicKey)
guard outgoing, message.deliveryStatus == .delivered, message.isRead else { return nil }
return "Read"
}
private func bubbleActions(for message: ChatMessage) -> [BubbleContextAction] {
var result: [BubbleContextAction] = []
// Desktop parity: system accounts + ATTACHMENTS_NOT_ALLOWED_TO_REPLY = [AVATAR, MESSAGES]
let isAvatarOrForwarded = message.attachments.contains(where: { $0.type == .avatar || $0.type == .messages })
let canReplyForward = !isSavedMessages && !isSystemAccount && !isAvatarOrForwarded
if canReplyForward {
result.append(BubbleContextAction(
title: "Reply",
image: UIImage(systemName: "arrowshape.turn.up.left"),
role: []
) { actions.onReply(message) })
}
result.append(BubbleContextAction(
title: "Copy",
image: UIImage(systemName: "doc.on.doc"),
role: []
) { actions.onCopy(message.text) })
if canReplyForward {
result.append(BubbleContextAction(
title: "Forward",
image: UIImage(systemName: "arrowshape.turn.up.right"),
role: []
) { actions.onForward(message) })
}
result.append(BubbleContextAction(
title: "Delete",
image: UIImage(systemName: "trash"),
role: .destructive
) { actions.onDelete(message) })
return result
private func contextMenuItems(for message: ChatMessage) -> [TelegramContextMenuItem] {
TelegramContextMenuBuilder.menuItems(
for: message,
actions: actions,
isSavedMessages: isSavedMessages,
isSystemAccount: isSystemAccount
)
}
// MARK: - Reply Quote

View File

@@ -9,7 +9,7 @@ import UIKit
/// 3. No SwiftUI, no UIHostingConfiguration, no self-sizing
///
/// Subviews are always present but hidden when not needed (no alloc/dealloc overhead).
final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDelegate {
final class NativeMessageCell: UICollectionViewCell {
// MARK: - Constants
@@ -400,9 +400,10 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
deliveryFailedButton.addTarget(self, action: #selector(handleDeliveryFailedTap), for: .touchUpInside)
contentView.addSubview(deliveryFailedButton)
// Interactions
let contextMenu = UIContextMenuInteraction(delegate: self)
bubbleView.addInteraction(contextMenu)
// Long-press Telegram context menu
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
longPress.minimumPressDuration = 0.35
bubbleView.addGestureRecognizer(longPress)
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleSwipe(_:)))
pan.delegate = self
@@ -527,6 +528,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
// Title (16pt medium Telegram parity)
fileNameLabel.font = UIFont.systemFont(ofSize: 16, weight: .medium)
fileNameLabel.textColor = .white
if isMissed {
fileNameLabel.text = isIncoming ? "Missed Call" : "Cancelled Call"
} else {
@@ -998,36 +1000,45 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
return attrs
}
// MARK: - Context Menu
// MARK: - Context Menu (Telegram-style)
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
configurationForMenuAtLocation location: CGPoint
) -> UIContextMenuConfiguration? {
guard let message, let actions else { return nil }
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in
var items: [UIAction] = []
if !message.text.isEmpty {
items.append(UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc")) { _ in
actions.onCopy(message.text)
})
}
// Desktop parity: system accounts + ATTACHMENTS_NOT_ALLOWED_TO_REPLY = [AVATAR, MESSAGES]
let isAvatarOrForwarded = message.attachments.contains(where: { $0.type == .avatar || $0.type == .messages })
let canReplyForward = !self.isSavedMessages && !self.isSystemAccount && !isAvatarOrForwarded
if canReplyForward {
items.append(UIAction(title: "Reply", image: UIImage(systemName: "arrowshape.turn.up.left")) { _ in
actions.onReply(message)
})
items.append(UIAction(title: "Forward", image: UIImage(systemName: "arrowshape.turn.up.right")) { _ in
actions.onForward(message)
})
}
items.append(UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { _ in
actions.onDelete(message)
})
return UIMenu(children: items)
}
private let contextMenuHaptic = UIImpactFeedbackGenerator(style: .medium)
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
guard gesture.state == .began else { return }
contextMenuHaptic.impactOccurred()
presentContextMenu()
}
private func presentContextMenu() {
guard let message, let actions else { return }
guard let layout = currentLayout else { return }
// Capture snapshot from window (pixel-perfect, accounts for inverted scroll)
guard let (snapshot, frame) = TelegramContextMenuController.captureSnapshot(of: bubbleView) else { return }
// Build bubble mask path
let bubblePath = BubbleGeometryEngine.makeBezierPath(
in: CGRect(origin: .zero, size: frame.size),
mergeType: layout.mergeType,
outgoing: layout.isOutgoing
)
// Build menu items
let items = TelegramContextMenuBuilder.menuItems(
for: message,
actions: actions,
isSavedMessages: isSavedMessages,
isSystemAccount: isSystemAccount
)
TelegramContextMenuController.present(
snapshot: snapshot,
sourceFrame: frame,
bubblePath: bubblePath,
items: items,
isOutgoing: layout.isOutgoing
)
}
// MARK: - Swipe to Reply

View File

@@ -17,9 +17,9 @@ final class NativeMessageListController: UIViewController {
static let messageToComposerGap: CGFloat = 16
static let scrollButtonSize: CGFloat = 40
static let scrollButtonIconCanvas: CGFloat = 38
static let scrollButtonBaseTrailing: CGFloat = 8
static let scrollButtonBaseTrailing: CGFloat = 17
static let scrollButtonCompactExtraTrailing: CGFloat = 18
static let scrollButtonBottomOffset: CGFloat = 20
}
// MARK: - Configuration
@@ -363,7 +363,7 @@ final class NativeMessageListController: UIViewController {
)
let bottom = container.bottomAnchor.constraint(
equalTo: view.keyboardLayoutGuide.topAnchor,
constant: -(lastComposerHeight + UIConstants.scrollButtonBottomOffset)
constant: -(lastComposerHeight + 4)
)
NSLayoutConstraint.activate([
container.widthAnchor.constraint(equalToConstant: size),
@@ -448,7 +448,7 @@ final class NativeMessageListController: UIViewController {
let safeBottom = view.safeAreaInsets.bottom
let compactShift = safeBottom <= 32 ? UIConstants.scrollButtonCompactExtraTrailing : 0
scrollToBottomTrailingConstraint?.constant = -(UIConstants.scrollButtonBaseTrailing + compactShift)
scrollToBottomBottomConstraint?.constant = -(lastComposerHeight + UIConstants.scrollButtonBottomOffset)
scrollToBottomBottomConstraint?.constant = -(lastComposerHeight + 4)
}
private func updateScrollToBottomBadge() {
@@ -491,10 +491,12 @@ final class NativeMessageListController: UIViewController {
gc.setLineCap(.round)
gc.setLineJoin(.round)
let position = CGPoint(x: 9.0 - 0.5, y: 23.0)
gc.move(to: CGPoint(x: position.x + 1.0, y: position.y - 1.0))
gc.addLine(to: CGPoint(x: position.x + 10.0, y: position.y - 10.0))
gc.addLine(to: CGPoint(x: position.x + 19.0, y: position.y - 1.0))
// Down chevron (v), centered in canvas Telegram parity.
let cx = size.width / 2
let cy = size.height / 2
gc.move(to: CGPoint(x: cx - 9.0, y: cy - 4.5)) // top-left
gc.addLine(to: CGPoint(x: cx, y: cy + 4.5)) // bottom-center
gc.addLine(to: CGPoint(x: cx + 9.0, y: cy - 4.5)) // top-right
gc.strokePath()
}.withRenderingMode(.alwaysOriginal)
}
@@ -1048,6 +1050,11 @@ struct NativeMessageListView: UIViewControllerRepresentable {
private func syncComposerState(_ controller: NativeMessageListController) {
guard let composer = controller.composerView else { return }
composer.setText(messageText)
#if DEBUG
if replySenderName != nil {
print("📋 syncComposer: sender=\(replySenderName ?? "nil") preview=\(replyPreviewText ?? "nil")")
}
#endif
composer.setReply(senderName: replySenderName, previewText: replyPreviewText)
composer.setFocused(isInputFocused)
}

View File

@@ -3,7 +3,7 @@ import UIKit
/// Pure UIKit message cell for text messages (with optional reply quote).
/// Replaces UIHostingConfiguration + SwiftUI for the most common message type.
/// Features: Figma-accurate bubble tail, context menu, swipe-to-reply, reply quote.
final class NativeTextBubbleCell: UICollectionViewCell, UIContextMenuInteractionDelegate {
final class NativeTextBubbleCell: UICollectionViewCell {
// MARK: - Constants
@@ -110,9 +110,10 @@ final class NativeTextBubbleCell: UICollectionViewCell, UIContextMenuInteraction
replyIconView.alpha = 0
contentView.addSubview(replyIconView)
// Context menu
let contextMenu = UIContextMenuInteraction(delegate: self)
bubbleView.addInteraction(contextMenu)
// Long-press Telegram context menu
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
longPress.minimumPressDuration = 0.35
bubbleView.addGestureRecognizer(longPress)
// Swipe-to-reply gesture
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleSwipe(_:)))
@@ -383,32 +384,45 @@ final class NativeTextBubbleCell: UICollectionViewCell, UIContextMenuInteraction
}
}
// MARK: - Context Menu
// MARK: - Context Menu (Telegram-style)
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
configurationForMenuAtLocation location: CGPoint
) -> UIContextMenuConfiguration? {
guard let message, let actions else { return nil }
private let contextMenuHaptic = UIImpactFeedbackGenerator(style: .medium)
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in
var items: [UIAction] = []
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
guard gesture.state == .began else { return }
contextMenuHaptic.impactOccurred()
presentContextMenu()
}
items.append(UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc")) { _ in
actions.onCopy(message.text)
})
items.append(UIAction(title: "Reply", image: UIImage(systemName: "arrowshape.turn.up.left")) { _ in
actions.onReply(message)
})
items.append(UIAction(title: "Forward", image: UIImage(systemName: "arrowshape.turn.up.right")) { _ in
actions.onForward(message)
})
items.append(UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { _ in
actions.onDelete(message)
})
private func presentContextMenu() {
guard let message, let actions else { return }
return UIMenu(children: items)
}
// Capture snapshot from window
guard let (snapshot, frame) = TelegramContextMenuController.captureSnapshot(of: bubbleView) else { return }
// Build bubble mask path
let mergeType = BubbleGeometryEngine.mergeType(for: position)
let bubblePath = BubbleGeometryEngine.makeBezierPath(
in: CGRect(origin: .zero, size: frame.size),
mergeType: mergeType,
outgoing: isOutgoing
)
// Build menu items
let items = TelegramContextMenuBuilder.menuItems(
for: message,
actions: actions,
isSavedMessages: false,
isSystemAccount: false
)
TelegramContextMenuController.present(
snapshot: snapshot,
sourceFrame: frame,
bubblePath: bubblePath,
items: items,
isOutgoing: isOutgoing
)
}
// MARK: - Swipe to Reply

View File

@@ -0,0 +1,178 @@
import UIKit
/// Telegram-exact context menu card.
///
/// Source: ContextActionsContainerNode.swift + DefaultDarkPresentationTheme.swift
/// - Background: .systemMaterialDark blur + UIColor(0x252525, alpha: 0.78) tint
/// - Items: 17pt font, icon LEFT + title RIGHT
/// - Corner radius: 14pt continuous
/// - Separator: screenPixel, white 15% alpha, full width
/// - Destructive: 0xeb5545
final class TelegramContextMenuCardView: UIView {
// MARK: - Constants
private static let itemHeight: CGFloat = 44
private static let cornerRadius: CGFloat = 14
private static let hPad: CGFloat = 16
private static let iconSize: CGFloat = 24
private static let screenPixel = 1.0 / max(UIScreen.main.scale, 1)
// MARK: - Colors (Telegram dark theme)
private static let tintBg = UIColor(red: 0x25/255, green: 0x25/255, blue: 0x25/255, alpha: 0.78)
private static let textColor = UIColor.white
private static let destructiveColor = UIColor(red: 0xEB/255, green: 0x55/255, blue: 0x45/255, alpha: 1)
private static let separatorColor = UIColor(white: 1, alpha: 0.15)
private static let highlightColor = UIColor(white: 1, alpha: 0.15)
// MARK: - Public
let itemCount: Int
var onItemSelected: (() -> Void)?
// MARK: - Views
private let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterialDark))
private let tintView = UIView()
private let items: [TelegramContextMenuItem]
// Row subviews stored for layout
private var titleLabels: [UILabel] = []
private var iconViews: [UIImageView] = []
private var highlightViews: [UIView] = []
private var separators: [UIView] = []
// MARK: - Init
init(items: [TelegramContextMenuItem]) {
self.items = items
self.itemCount = items.count
super.init(frame: .zero)
setup()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
// MARK: - Setup
private func setup() {
clipsToBounds = true
layer.cornerRadius = Self.cornerRadius
layer.cornerCurve = .continuous
blurView.clipsToBounds = true
addSubview(blurView)
tintView.backgroundColor = Self.tintBg
tintView.isUserInteractionEnabled = false
addSubview(tintView)
for (i, item) in items.enumerated() {
let color = item.isDestructive ? Self.destructiveColor : Self.textColor
// Highlight
let hl = UIView()
hl.backgroundColor = Self.highlightColor
hl.alpha = 0
addSubview(hl)
highlightViews.append(hl)
// Title
let label = UILabel()
label.text = item.title
label.font = .systemFont(ofSize: 17, weight: .regular)
label.textColor = color
addSubview(label)
titleLabels.append(label)
// Icon
let iv = UIImageView()
iv.image = UIImage(systemName: item.iconName)?
.withConfiguration(UIImage.SymbolConfiguration(pointSize: Self.iconSize, weight: .medium))
iv.tintColor = color
iv.contentMode = .center
addSubview(iv)
iconViews.append(iv)
// Separator
if i < items.count - 1 {
let sep = UIView()
sep.backgroundColor = Self.separatorColor
addSubview(sep)
separators.append(sep)
}
// Row gesture
let rowView = UIView()
rowView.backgroundColor = .clear
rowView.tag = i
let press = UILongPressGestureRecognizer(target: self, action: #selector(rowPress(_:)))
press.minimumPressDuration = 0
press.cancelsTouchesInView = false
rowView.addGestureRecognizer(press)
addSubview(rowView)
}
}
// MARK: - Layout
override func layoutSubviews() {
super.layoutSubviews()
let w = bounds.width
blurView.frame = bounds
tintView.frame = bounds
for i in 0..<items.count {
let y = CGFloat(i) * Self.itemHeight
let rowRect = CGRect(x: 0, y: y, width: w, height: Self.itemHeight)
// Highlight fills row
highlightViews[i].frame = rowRect
// Icon left (Telegram: icon on LEFT side)
let iconX = Self.hPad
iconViews[i].frame = CGRect(x: iconX, y: y, width: Self.iconSize, height: Self.itemHeight)
// Title right of icon (Telegram: text follows icon)
let titleX = Self.hPad + Self.iconSize + 12
let titleW = w - titleX - Self.hPad
titleLabels[i].frame = CGRect(x: titleX, y: y, width: titleW, height: Self.itemHeight)
// Gesture receiver (topmost, transparent)
if let rv = subviews.first(where: { $0.tag == i && $0.gestureRecognizers?.isEmpty == false }) {
rv.frame = rowRect
}
}
// Separators
for (i, sep) in separators.enumerated() {
let y = CGFloat(i + 1) * Self.itemHeight
sep.frame = CGRect(x: 0, y: y, width: w, height: Self.screenPixel)
}
}
// MARK: - Interaction
@objc private func rowPress(_ g: UILongPressGestureRecognizer) {
let i = g.view?.tag ?? -1
guard i >= 0, i < items.count else { return }
switch g.state {
case .began:
UIView.animate(withDuration: 0.08) { self.highlightViews[i].alpha = 1 }
case .ended:
let loc = g.location(in: g.view)
UIView.animate(withDuration: 0.12) { self.highlightViews[i].alpha = 0 }
if g.view?.bounds.contains(loc) == true {
let handler = items[i].handler
onItemSelected?()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { handler() }
}
case .cancelled, .failed:
UIView.animate(withDuration: 0.12) { self.highlightViews[i].alpha = 0 }
default: break
}
}
}

View File

@@ -0,0 +1,370 @@
import UIKit
// MARK: - Menu Item Model
struct TelegramContextMenuItem {
let title: String
let iconName: String
let isDestructive: Bool
let handler: () -> Void
}
// MARK: - Shared Builder
enum TelegramContextMenuBuilder {
static func menuItems(
for message: ChatMessage,
actions: MessageCellActions,
isSavedMessages: Bool,
isSystemAccount: Bool
) -> [TelegramContextMenuItem] {
var items: [TelegramContextMenuItem] = []
let isAvatarOrForwarded = message.attachments.contains(where: {
$0.type == .avatar || $0.type == .messages
})
let canReplyForward = !isSavedMessages && !isSystemAccount && !isAvatarOrForwarded
if canReplyForward {
items.append(TelegramContextMenuItem(
title: "Reply",
iconName: "arrowshape.turn.up.left",
isDestructive: false,
handler: { actions.onReply(message) }
))
}
if !message.text.isEmpty {
items.append(TelegramContextMenuItem(
title: "Copy",
iconName: "doc.on.doc",
isDestructive: false,
handler: { actions.onCopy(message.text) }
))
}
if canReplyForward {
items.append(TelegramContextMenuItem(
title: "Forward",
iconName: "arrowshape.turn.up.right",
isDestructive: false,
handler: { actions.onForward(message) }
))
}
items.append(TelegramContextMenuItem(
title: "Delete",
iconName: "trash",
isDestructive: true,
handler: { actions.onDelete(message) }
))
return items
}
}
// MARK: - Context Menu Controller
/// Full-screen overlay replicating Telegram iOS context menu.
/// Telegram source: ContextController.swift, DefaultDarkPresentationTheme.swift
final class TelegramContextMenuController: UIView {
// MARK: - Constants
private static let menuWidth: CGFloat = 250
private static let menuItemHeight: CGFloat = 44
private static let menuGap: CGFloat = 8
private static let panDismissThreshold: CGFloat = 100
private static let panVelocityThreshold: CGFloat = 800
// MARK: - Subviews
/// Telegram: UIVisualEffectView(.dark) + UIColor(rgb: 0x000000, alpha: 0.6) dim
private let backgroundBlurView = UIVisualEffectView(effect: UIBlurEffect(style: .dark))
private let dimView = UIView()
private let snapshotContainer = UIView()
private let snapshotImageView = UIImageView()
private let menuCard: TelegramContextMenuCardView
// MARK: - State
private let sourceFrame: CGRect
private let isOutgoing: Bool
private var onDismiss: (() -> Void)?
private var isDismissing = false
private var panStartSnapshotCenter: CGPoint = .zero
private var panStartMenuCenter: CGPoint = .zero
// MARK: - Init
private init(
snapshot: UIImage,
sourceFrame: CGRect,
bubblePath: UIBezierPath,
items: [TelegramContextMenuItem],
isOutgoing: Bool,
onDismiss: (() -> Void)?
) {
self.sourceFrame = sourceFrame
self.isOutgoing = isOutgoing
self.onDismiss = onDismiss
self.menuCard = TelegramContextMenuCardView(items: items)
super.init(frame: .zero)
buildHierarchy(snapshot: snapshot, bubblePath: bubblePath)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
// MARK: - Public API
@MainActor
static func present(
snapshot: UIImage,
sourceFrame: CGRect,
bubblePath: UIBezierPath,
items: [TelegramContextMenuItem],
isOutgoing: Bool,
onDismiss: (() -> Void)? = nil
) {
guard !items.isEmpty else { return }
guard let scene = UIApplication.shared.connectedScenes
.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene,
let window = scene.windows.first(where: \.isKeyWindow)
else { return }
// Telegram always dismisses keyboard before showing context menu
window.endEditing(true)
let overlay = TelegramContextMenuController(
snapshot: snapshot,
sourceFrame: sourceFrame,
bubblePath: bubblePath,
items: items,
isOutgoing: isOutgoing,
onDismiss: onDismiss
)
overlay.frame = window.bounds
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
window.addSubview(overlay)
overlay.performPresentation()
}
// MARK: - Build Hierarchy
private func buildHierarchy(snapshot: UIImage, bubblePath: UIBezierPath) {
// 1. Full-screen blur (Telegram: custom zoom blur + .systemMaterialDark)
backgroundBlurView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
addSubview(backgroundBlurView)
// 2. Dim overlay on top of blur (Telegram: UIColor(rgb: 0x000000, alpha: 0.6))
dimView.backgroundColor = UIColor(white: 0, alpha: 0.6)
dimView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
addSubview(dimView)
// 2. Snapshot container
snapshotContainer.backgroundColor = .clear
snapshotImageView.image = snapshot
snapshotImageView.contentMode = .scaleToFill
let mask = CAShapeLayer()
mask.path = bubblePath.cgPath
snapshotImageView.layer.mask = mask
snapshotContainer.layer.shadowColor = UIColor.black.cgColor
snapshotContainer.layer.shadowOpacity = 0.3
snapshotContainer.layer.shadowRadius = 12
snapshotContainer.layer.shadowOffset = CGSize(width: 0, height: 4)
snapshotContainer.layer.shadowPath = bubblePath.cgPath
snapshotContainer.addSubview(snapshotImageView)
addSubview(snapshotContainer)
// 3. Menu card
menuCard.onItemSelected = { [weak self] in
self?.performDismissal()
}
addSubview(menuCard)
// 4. Gestures
let tap = UITapGestureRecognizer(target: self, action: #selector(tapDismiss(_:)))
tap.delegate = self
addGestureRecognizer(tap)
let pan = UIPanGestureRecognizer(target: self, action: #selector(panDismiss(_:)))
addGestureRecognizer(pan)
}
// MARK: - Layout
/// Positions snapshot + menu card. MUST be called with identity transforms.
private func layoutContent() {
let windowInsets = window?.safeAreaInsets ?? .zero
let safeTop = max(windowInsets.top, 20)
let safeBottom = max(windowInsets.bottom, 20)
// Snapshot at original bubble position
snapshotContainer.frame = sourceFrame
snapshotImageView.frame = snapshotContainer.bounds
// Menu dimensions
let menuW = Self.menuWidth
let menuH = CGFloat(menuCard.itemCount) * Self.menuItemHeight
let gap = Self.menuGap
// Vertical: prefer below, then above, then shift
let belowSpace = bounds.height - safeBottom - sourceFrame.maxY
let aboveSpace = sourceFrame.minY - safeTop
var menuY: CGFloat
var menuAbove = false
if belowSpace >= menuH + gap {
menuY = sourceFrame.maxY + gap
} else if aboveSpace >= menuH + gap {
menuY = sourceFrame.minY - gap - menuH
menuAbove = true
} else {
// Shift snapshot up to make room below
let shift = (menuH + gap) - belowSpace
snapshotContainer.frame.origin.y -= shift
menuY = snapshotContainer.frame.maxY + gap
}
// Horizontal: align with bubble side
let menuX: CGFloat
if isOutgoing {
menuX = min(sourceFrame.maxX - menuW, bounds.width - menuW - 8)
} else {
menuX = max(sourceFrame.minX, 8)
}
menuCard.frame = CGRect(x: menuX, y: menuY, width: menuW, height: menuH)
// Anchor for scale animation scale from the edge nearest to the bubble
let anchorX: CGFloat = isOutgoing ? 1.0 : 0.0
let anchorY: CGFloat = menuAbove ? 1.0 : 0.0
setAnchorPointPreservingPosition(CGPoint(x: anchorX, y: anchorY), for: menuCard)
}
/// Changes anchor point without moving the view.
private func setAnchorPointPreservingPosition(_ anchor: CGPoint, for view: UIView) {
let oldAnchor = view.layer.anchorPoint
let delta = CGPoint(
x: (anchor.x - oldAnchor.x) * view.bounds.width,
y: (anchor.y - oldAnchor.y) * view.bounds.height
)
view.layer.anchorPoint = anchor
view.layer.position = CGPoint(
x: view.layer.position.x + delta.x,
y: view.layer.position.y + delta.y
)
}
// MARK: - Presentation
private func performPresentation() {
// Layout with identity transforms (CRITICAL frame + transform interaction)
backgroundBlurView.frame = bounds
dimView.frame = bounds
layoutContent()
// Set initial pre-animation state
backgroundBlurView.alpha = 0
dimView.alpha = 0
snapshotContainer.alpha = 0
snapshotContainer.transform = CGAffineTransform(scaleX: 0.96, y: 0.96)
menuCard.alpha = 0
menuCard.transform = CGAffineTransform(scaleX: 0.01, y: 0.01)
// Telegram spring: mass 5, stiffness 900, damping 88
let spring = UISpringTimingParameters(
mass: 5.0, stiffness: 900.0, damping: 88.0, initialVelocity: .zero
)
let animator = UIViewPropertyAnimator(duration: 0, timingParameters: spring)
animator.addAnimations {
self.backgroundBlurView.alpha = 1
self.dimView.alpha = 1
self.snapshotContainer.alpha = 1
self.snapshotContainer.transform = .identity
self.menuCard.alpha = 1
self.menuCard.transform = .identity
}
animator.startAnimation()
}
// MARK: - Dismissal
private func performDismissal() {
guard !isDismissing else { return }
isDismissing = true
UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseIn) {
self.backgroundBlurView.alpha = 0
self.dimView.alpha = 0
self.snapshotContainer.alpha = 0
self.menuCard.alpha = 0
self.menuCard.transform = CGAffineTransform(scaleX: 0.01, y: 0.01)
} completion: { [weak self] _ in
self?.onDismiss?()
self?.removeFromSuperview()
}
}
// MARK: - Gestures
@objc private func tapDismiss(_ g: UITapGestureRecognizer) {
performDismissal()
}
@objc private func panDismiss(_ g: UIPanGestureRecognizer) {
switch g.state {
case .began:
panStartSnapshotCenter = snapshotContainer.center
panStartMenuCenter = menuCard.center
case .changed:
let dy = max(g.translation(in: self).y, 0) * 0.6
snapshotContainer.center = CGPoint(x: panStartSnapshotCenter.x, y: panStartSnapshotCenter.y + dy)
menuCard.center = CGPoint(x: panStartMenuCenter.x, y: panStartMenuCenter.y + dy)
dimView.alpha = max(1 - dy / 300, 0.3)
case .ended, .cancelled:
if g.translation(in: self).y > Self.panDismissThreshold || g.velocity(in: self).y > Self.panVelocityThreshold {
performDismissal()
} else {
UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0) {
self.snapshotContainer.center = self.panStartSnapshotCenter
self.menuCard.center = self.panStartMenuCenter
self.dimView.alpha = 1
}
}
default: break
}
}
}
// MARK: - UIGestureRecognizerDelegate
extension TelegramContextMenuController: UIGestureRecognizerDelegate {
func gestureRecognizer(_ g: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
guard g is UITapGestureRecognizer else { return true }
let loc = touch.location(in: self)
return !snapshotContainer.frame.contains(loc) && !menuCard.frame.contains(loc)
}
}
// MARK: - Snapshot Helper
extension TelegramContextMenuController {
static func captureSnapshot(of sourceView: UIView) -> (image: UIImage, frame: CGRect)? {
guard let window = sourceView.window else { return nil }
let frameInWindow = sourceView.convert(sourceView.bounds, to: window)
let renderer = UIGraphicsImageRenderer(size: sourceView.bounds.size)
let image = renderer.image { ctx in
ctx.cgContext.translateBy(x: -frameInWindow.origin.x, y: -frameInWindow.origin.y)
window.drawHierarchy(in: window.bounds, afterScreenUpdates: false)
}
return (image, frameInWindow)
}
}