Push-уведомления: Telegram-parity in-app баннер, threadIdentifier группировка и letter-avatar в NSE

This commit is contained in:
2026-04-01 18:33:59 +05:00
parent 79c5635715
commit 4be6761492
20 changed files with 1347 additions and 240 deletions

View File

@@ -12,8 +12,9 @@ struct BubbleContextMenuOverlay: UIViewRepresentable {
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).
var onTap: ((CGPoint) -> Void)?
/// coordinate space (for determining which sub-element was tapped, e.g., which photo in a collage)
/// and the overlay UIView (for converting sub-rects to global coordinates).
var onTap: ((CGPoint, UIView?) -> Void)?
/// Height of the reply quote area at the top of the bubble (0 = no reply quote).
/// Taps within this region call `onReplyQuoteTap` instead of `onTap`.
@@ -57,7 +58,7 @@ struct BubbleContextMenuOverlay: UIViewRepresentable {
var items: [TelegramContextMenuItem]
var previewShape: MessageBubbleShape
var isOutgoing: Bool
var onTap: ((CGPoint) -> Void)?
var onTap: ((CGPoint, UIView?) -> Void)?
var replyQuoteHeight: CGFloat = 0
var onReplyQuoteTap: (() -> Void)?
private let haptic = UIImpactFeedbackGenerator(style: .medium)
@@ -83,7 +84,7 @@ struct BubbleContextMenuOverlay: UIViewRepresentable {
}
}
let location = recognizer.location(in: recognizer.view)
onTap?(location)
onTap?(location, recognizer.view)
}
// MARK: - Long Press Context Menu

View File

@@ -210,7 +210,7 @@ struct ChatDetailView: View {
cellActions.onForward = { [self] msg in forwardingMessage = msg; showForwardPicker = true }
cellActions.onDelete = { [self] msg in messageToDelete = msg }
cellActions.onCopy = { text in UIPasteboard.general.string = text }
cellActions.onImageTap = { [self] attId in openImageViewer(attachmentId: attId) }
cellActions.onImageTap = { [self] attId, frame in openImageViewer(attachmentId: attId, sourceFrame: frame) }
cellActions.onScrollToMessage = { [self] msgId in
Task { @MainActor in
guard await viewModel.ensureMessageLoaded(messageId: msgId) else { return }
@@ -303,11 +303,13 @@ struct ChatDetailView: View {
OpponentProfileView(route: route)
}
.sheet(isPresented: $showForwardPicker) {
ForwardChatPickerView { targetRoute in
ForwardChatPickerView { targetRoutes in
showForwardPicker = false
guard let message = forwardingMessage else { return }
forwardingMessage = nil
forwardMessage(message, to: targetRoute)
for route in targetRoutes {
forwardMessage(message, to: route)
}
}
}
// Image viewer: presented via ImageViewerPresenter (UIKit overFullScreen + crossDissolve).
@@ -1142,7 +1144,7 @@ private extension ChatDetailView {
/// sender name, timestamp, and caption for each image.
/// Uses `ImageViewerPresenter` (UIKit overFullScreen) instead of SwiftUI fullScreenCover
/// to avoid the default bottom-sheet slide-up animation.
func openImageViewer(attachmentId: String) {
func openImageViewer(attachmentId: String, sourceFrame: CGRect) {
var allImages: [ViewableImageInfo] = []
for message in messages {
let senderName = senderDisplayName(for: message.fromPublicKey)
@@ -1177,7 +1179,7 @@ private extension ChatDetailView {
}
}
let index = allImages.firstIndex(where: { $0.attachmentId == attachmentId }) ?? 0
let state = ImageViewerState(images: allImages, initialIndex: index)
let state = ImageViewerState(images: allImages, initialIndex: index, sourceFrame: sourceFrame)
ImageViewerPresenter.shared.present(state: state)
}

View File

@@ -1,13 +1,15 @@
import SwiftUI
/// Telegram-style forward picker sheet.
/// Shows search bar + chat list with Saved Messages always first.
/// Two modes: single-tap (immediate forward) and multi-select (checkboxes + send button).
struct ForwardChatPickerView: View {
let onSelect: (ChatRoute) -> Void
let onSelect: ([ChatRoute]) -> Void
@Environment(\.dismiss) private var dismiss
@State private var searchText = ""
@State private var isMultiSelect = false
@State private var selectedIds: Set<String> = []
/// Filtered + sorted dialogs: Saved Messages first, then pinned, then recent.
/// Filtered + sorted dialogs: Saved Messages always first, then pinned, then recent.
private var dialogs: [Dialog] {
let all = DialogRepository.shared.sortedDialogs.filter {
($0.iHaveSent || $0.isSavedMessages) && !SystemAccounts.isSystemAccount($0.opponentKey)
@@ -25,7 +27,6 @@ struct ForwardChatPickerView: View {
}
}
// Saved Messages always first
var saved: Dialog?
var rest: [Dialog] = []
for dialog in filtered {
@@ -35,67 +36,183 @@ struct ForwardChatPickerView: View {
rest.append(dialog)
}
}
if let saved {
return [saved] + rest
}
if let saved { return [saved] + rest }
return rest
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
ForwardPickerSearchBar(searchText: $searchText)
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 8)
if dialogs.isEmpty && !searchText.isEmpty {
VStack {
Spacer()
Text("No chats found")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
Spacer()
VStack(spacing: 0) {
// MARK: - Header
ForwardPickerHeader(
isMultiSelect: isMultiSelect,
onClose: { dismiss() },
onSelect: {
withAnimation(.easeInOut(duration: 0.2)) {
isMultiSelect = true
}
} else {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(Array(dialogs.enumerated()), id: \.element.id) { index, dialog in
ForwardPickerRow(dialog: dialog) {
onSelect(ChatRoute(dialog: dialog))
}
}
)
if index < dialogs.count - 1 {
Divider()
.padding(.leading, 70)
.foregroundStyle(RosettaColors.Adaptive.divider)
// MARK: - Search
ForwardPickerSearchBar(searchText: $searchText)
.padding(.horizontal, 8)
.padding(.top, 8)
.padding(.bottom, 6)
// MARK: - Chat List
if dialogs.isEmpty && !searchText.isEmpty {
VStack {
Spacer()
Text("No chats found")
.font(.system(size: 15))
.foregroundStyle(Color(white: 0.5))
Spacer()
}
} else {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(Array(dialogs.enumerated()), id: \.element.id) { index, dialog in
ForwardPickerRow(
dialog: dialog,
isMultiSelect: isMultiSelect,
isSelected: selectedIds.contains(dialog.opponentKey)
) {
if isMultiSelect {
withAnimation(.easeInOut(duration: 0.15)) {
if selectedIds.contains(dialog.opponentKey) {
selectedIds.remove(dialog.opponentKey)
} else {
selectedIds.insert(dialog.opponentKey)
}
}
} else {
onSelect([ChatRoute(dialog: dialog)])
}
}
if index < dialogs.count - 1 {
Divider()
.padding(.leading, 65)
.foregroundStyle(Color(red: 0x54 / 255.0, green: 0x54 / 255.0, blue: 0x58 / 255.0).opacity(0.55))
}
}
}
.scrollDismissesKeyboard(.interactively)
}
.scrollDismissesKeyboard(.interactively)
}
.background(RosettaColors.Dark.background.ignoresSafeArea())
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
// MARK: - Bottom Bar (multi-select)
if isMultiSelect {
ForwardPickerBottomBar(
selectedCount: selectedIds.count,
onSend: {
let routes = dialogs
.filter { selectedIds.contains($0.opponentKey) }
.map { ChatRoute(dialog: $0) }
if !routes.isEmpty { onSelect(routes) }
}
)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.background(Color.black.ignoresSafeArea())
.preferredColorScheme(.dark)
.presentationBackground(Color.black)
.presentationDragIndicator(.visible)
}
}
// MARK: - Telegram Icons (CoreGraphics-exact replicas)
/// Telegram X close icon: two diagonal lines, 2pt stroke, round cap.
/// Source: PresentationResourcesChat.swift:215
private struct TelegramCloseIcon: Shape {
func path(in rect: CGRect) -> Path {
var p = Path()
let inset: CGFloat = 1.0
p.move(to: CGPoint(x: inset, y: inset))
p.addLine(to: CGPoint(x: rect.width - inset, y: rect.height - inset))
p.move(to: CGPoint(x: rect.width - inset, y: inset))
p.addLine(to: CGPoint(x: inset, y: rect.height - inset))
return p
}
}
/// Telegram send arrow: arrowhead + vertical stem.
/// Source: PresentationResourcesChat.swift:365 (SVG paths)
private struct TelegramSendArrow: Shape {
func path(in rect: CGRect) -> Path {
let sx = rect.width / 33.0
let sy = rect.height / 33.0
var p = Path()
// Arrowhead (V pointing up)
p.move(to: CGPoint(x: 11 * sx, y: 14.667 * sy))
p.addLine(to: CGPoint(x: 16.5 * sx, y: 9.4 * sy))
p.addLine(to: CGPoint(x: 22 * sx, y: 14.667 * sy))
// Stem (vertical rounded rect)
p.addRoundedRect(
in: CGRect(x: 15.5 * sx, y: 9.333 * sy, width: 2 * sx, height: 15.667 * sy),
cornerSize: CGSize(width: 1 * sx, height: 1 * sy)
)
return p
}
}
/// Telegram checkmark: L-shaped path, 1.5pt stroke, round cap/join.
/// Source: CheckNode.swift:468-600
private struct TelegramCheckmark: Shape {
func path(in rect: CGRect) -> Path {
let scale = min(rect.width, rect.height) / 18.0
let cx = rect.midX
let cy = rect.midY
let start = CGPoint(x: cx - 3.5 * scale, y: cy + 0.5 * scale)
var p = Path()
p.move(to: start)
p.addLine(to: CGPoint(x: start.x + 2.5 * scale, y: start.y + 3.0 * scale))
p.addLine(to: CGPoint(x: start.x + 2.5 * scale + 4.667 * scale, y: start.y + 3.0 * scale - 6.0 * scale))
return p
}
}
// MARK: - Header
private struct ForwardPickerHeader: View {
let isMultiSelect: Bool
let onClose: () -> Void
let onSelect: () -> Void
var body: some View {
ZStack {
HStack {
// Telegram close button: 30pt dark circle + 12x12 X icon
Button(action: onClose) {
ZStack {
Circle()
.fill(Color(white: 0.16))
.frame(width: 30, height: 30)
TelegramCloseIcon()
.stroke(.white, style: StrokeStyle(lineWidth: 2, lineCap: .round))
.frame(width: 12, height: 12)
}
}
ToolbarItem(placement: .principal) {
Text("Forward")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
Spacer()
}
Text("Forward")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white)
if !isMultiSelect {
HStack {
Spacer()
Button("Select", action: onSelect)
.font(.system(size: 17, weight: .regular))
.foregroundStyle(.white)
}
}
.toolbarBackground(RosettaColors.Dark.background, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
}
.preferredColorScheme(.dark)
.padding(.horizontal, 16)
.frame(height: 52)
}
}
@@ -106,32 +223,39 @@ private struct ForwardPickerSearchBar: View {
@FocusState private var isFocused: Bool
var body: some View {
HStack(spacing: 6) {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(Color.gray)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(Color(white: 0.56))
TextField("Search", text: $searchText)
.font(.system(size: 17))
.foregroundStyle(RosettaColors.Adaptive.text)
.focused($isFocused)
.submitLabel(.search)
ZStack(alignment: .leading) {
if searchText.isEmpty {
Text("Search")
.font(.system(size: 17))
.foregroundStyle(Color(white: 0.56))
}
TextField("", text: $searchText)
.font(.system(size: 17))
.foregroundStyle(.white)
.focused($isFocused)
.submitLabel(.search)
}
if !searchText.isEmpty {
Button {
searchText = ""
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 15))
.foregroundStyle(Color.gray)
.font(.system(size: 16))
.foregroundStyle(Color(white: 0.56))
}
}
}
.padding(.horizontal, 12)
.frame(height: 42)
.padding(.horizontal, 14)
.frame(height: 44)
.background {
RoundedRectangle(cornerRadius: 21, style: .continuous)
.fill(RosettaColors.Adaptive.backgroundSecondary)
RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(Color(white: 0.14))
}
}
}
@@ -140,28 +264,34 @@ private struct ForwardPickerSearchBar: View {
private struct ForwardPickerRow: View {
let dialog: Dialog
let isMultiSelect: Bool
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 12) {
HStack(spacing: 10) {
ForwardPickerRowAvatar(dialog: dialog)
HStack(spacing: 4) {
Text(dialog.isSavedMessages ? "Saved Messages" : dialog.opponentTitle)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
.font(.system(size: 17, weight: .regular))
.foregroundStyle(.white)
.lineLimit(1)
if dialog.effectiveVerified > 0 && !dialog.isSavedMessages {
VerifiedBadge(verified: dialog.effectiveVerified, size: 14)
VerifiedBadge(verified: dialog.effectiveVerified, size: 16)
}
}
Spacer()
if isMultiSelect {
SelectionCircle(isSelected: isSelected)
}
}
.padding(.horizontal, 16)
.frame(height: 56)
.padding(.leading, 15).padding(.trailing, 16)
.frame(height: 48)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
@@ -178,9 +308,80 @@ private struct ForwardPickerRowAvatar: View {
AvatarView(
initials: dialog.initials,
colorIndex: dialog.avatarColorIndex,
size: 42,
size: 40,
isSavedMessages: dialog.isSavedMessages,
image: dialog.isSavedMessages ? nil : AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey)
)
}
}
// MARK: - Selection Circle (Telegram CheckNode replica)
private struct SelectionCircle: View {
let isSelected: Bool
var body: some View {
ZStack {
if isSelected {
Circle()
.fill(RosettaColors.primaryBlue)
.frame(width: 22, height: 22)
TelegramCheckmark()
.stroke(.white, style: StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round))
.frame(width: 22, height: 22)
} else {
Circle()
.stroke(Color(white: 0.35), lineWidth: 1.5)
.frame(width: 22, height: 22)
}
}
}
}
// MARK: - Bottom Bar
private struct ForwardPickerBottomBar: View {
let selectedCount: Int
let onSend: () -> Void
var body: some View {
VStack(spacing: 0) {
Rectangle()
.fill(Color(white: 0.2))
.frame(height: 0.5)
HStack(spacing: 10) {
HStack(spacing: 8) {
Text("Message")
.font(.system(size: 17))
.foregroundStyle(Color(white: 0.35))
Spacer()
}
.padding(.horizontal, 16)
.frame(height: 42)
.background {
RoundedRectangle(cornerRadius: 21, style: .continuous)
.fill(Color(white: 0.14))
}
// Telegram send button: 33pt circle + SVG arrow
Button(action: onSend) {
ZStack {
Circle()
.fill(selectedCount > 0
? RosettaColors.primaryBlue
: Color(white: 0.2))
.frame(width: 33, height: 33)
TelegramSendArrow()
.fill(.white)
.frame(width: 33, height: 33)
}
}
.disabled(selectedCount == 0)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
}
.background(Color.black)
}
}

View File

@@ -19,6 +19,7 @@ struct ViewableImageInfo: Equatable, Identifiable {
struct ImageViewerState: Equatable {
let images: [ViewableImageInfo]
let initialIndex: Int
let sourceFrame: CGRect
}
// MARK: - ImageViewerPresenter
@@ -71,7 +72,8 @@ final class ImageViewerPresenter {
// MARK: - ImageGalleryViewer
/// Telegram-style multi-photo gallery viewer with horizontal paging.
/// Telegram-style multi-photo gallery viewer with hero transition animation.
/// Reference: PhotosTransition/Helpers/PhotoGridView.swift hero expand/collapse pattern.
/// Android parity: `ImageViewerScreen.kt` top bar with sender/date,
/// bottom caption bar, edge-tap navigation, velocity dismiss, share/save.
struct ImageGalleryViewer: View {
@@ -82,10 +84,13 @@ struct ImageGalleryViewer: View {
@State private var currentPage: Int
@State private var showControls = true
@State private var currentZoomScale: CGFloat = 1.0
@State private var backgroundOpacity: Double = 1.0
@State private var isDismissing = false
/// Entry/exit animation progress (0 = hidden, 1 = fully visible).
@State private var presentationAlpha: Double = 0
/// Hero transition state: false = positioned at source frame, true = fullscreen.
@State private var isExpanded: Bool = false
/// Drag offset for interactive pan-to-dismiss.
@State private var dragOffset: CGSize = .zero
/// Full screen dimensions (captured from geometry).
@State private var viewSize: CGSize = UIScreen.main.bounds.size
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
@@ -103,27 +108,37 @@ struct ImageGalleryViewer: View {
state.images.indices.contains(currentPage) ? state.images[currentPage] : nil
}
/// Whether the source frame is valid for hero animation (non-zero).
private var hasHeroSource: Bool {
state.sourceFrame.width > 0 && state.sourceFrame.height > 0
}
/// Hero animation spring matches PhotosTransition reference.
private var heroAnimation: Animation {
.interpolatingSpring(duration: 0.3, bounce: 0, initialVelocity: 0)
}
/// Opacity that decreases as user drags further from center.
private var interactiveOpacity: CGFloat {
let opacityY = abs(dragOffset.height) / (viewSize.height * 0.3)
return max(1 - opacityY, 0)
}
var body: some View {
let sourceFrame = state.sourceFrame
ZStack {
// Background fades during drag-to-dismiss and entry/exit
// Background fades with hero expansion and drag progress
Color.black
.opacity(backgroundOpacity * presentationAlpha)
.opacity(isExpanded ? interactiveOpacity : 0)
.ignoresSafeArea()
// Pager
// Pager with hero positioning
TabView(selection: $currentPage) {
ForEach(Array(state.images.enumerated()), id: \.element.attachmentId) { index, info in
ZoomableImagePage(
attachmentId: info.attachmentId,
onDismiss: { smoothDismiss() },
onDismissProgress: { progress in
backgroundOpacity = 1.0 - Double(progress) * 0.7
},
onDismissCancel: {
withAnimation(.easeOut(duration: 0.25)) {
backgroundOpacity = 1.0
}
},
onDismiss: { dismiss() },
showControls: $showControls,
currentScale: $currentZoomScale,
onEdgeTap: { direction in
@@ -134,23 +149,59 @@ struct ImageGalleryViewer: View {
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
// Block TabView page swipe when zoomed or dismissing,
// but ONLY the scroll NOT all user interaction.
// .disabled() kills ALL gestures (taps, pinch, etc.) which prevents
// double-tap zoom out. .scrollDisabled() only blocks the page swipe.
.scrollDisabled(currentZoomScale > 1.05 || isDismissing)
.opacity(presentationAlpha)
// Hero frame: source rect when collapsed, full screen when expanded
.frame(
width: isExpanded ? viewSize.width : (hasHeroSource ? sourceFrame.width : viewSize.width),
height: isExpanded ? viewSize.height : (hasHeroSource ? sourceFrame.height : viewSize.height)
)
.clipped()
.offset(
x: isExpanded ? 0 : (hasHeroSource ? sourceFrame.minX : 0),
y: isExpanded ? 0 : (hasHeroSource ? sourceFrame.minY : 0)
)
.offset(dragOffset)
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: isExpanded ? .center : (hasHeroSource ? .topLeading : .center)
)
.ignoresSafeArea()
// Interactive drag gesture for hero dismiss (vertical only, when not zoomed)
.simultaneousGesture(
currentZoomScale <= 1.05 ?
DragGesture(minimumDistance: 40)
.onChanged { value in
let dy = abs(value.translation.height)
let dx = abs(value.translation.width)
guard dy > dx * 2.0 else { return }
dragOffset = .init(width: value.translation.width, height: value.translation.height)
}
.onEnded { value in
if dragOffset.height > 50 {
heroDismiss()
} else {
withAnimation(heroAnimation.speed(1.2)) {
dragOffset = .zero
}
}
}
: nil
)
// Controls overlay
// Controls overlay fades with hero expansion
controlsOverlay
.opacity(presentationAlpha)
.opacity(isExpanded ? 1 : 0)
.opacity(interactiveOpacity)
}
.statusBarHidden(true)
.onAppear {
.allowsHitTesting(isExpanded)
.onGeometryChange(for: CGSize.self, of: { $0.size }) { viewSize = $0 }
.task {
prefetchAdjacentImages(around: state.initialIndex)
// Android: 200ms entry animation (TelegramEasing)
withAnimation(.easeOut(duration: 0.2)) {
presentationAlpha = 1.0
guard !isExpanded else { return }
withAnimation(heroAnimation) {
isExpanded = true
}
}
.onChange(of: currentPage) { _, newPage in
@@ -163,7 +214,6 @@ struct ImageGalleryViewer: View {
@ViewBuilder
private var controlsOverlay: some View {
VStack(spacing: 0) {
// Android parity: slide + fade, 200ms, FastOutSlowInEasing, 24pt slide distance.
if showControls && !isDismissing {
topBar
.transition(.move(edge: .top).combined(with: .opacity))
@@ -177,19 +227,17 @@ struct ImageGalleryViewer: View {
.animation(.easeOut(duration: 0.2), value: showControls)
}
// MARK: - Top Bar (Android: sender name + date, back arrow)
// MARK: - Top Bar
private var topBar: some View {
HStack(spacing: 8) {
// Back button (Android: arrow back on left)
Button { smoothDismiss() } label: {
Button { dismiss() } label: {
Image(systemName: "chevron.left")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(.white)
.frame(width: 44, height: 44)
}
// Sender name + date
if let info = currentInfo {
VStack(alignment: .leading, spacing: 1) {
Text(info.senderName)
@@ -205,7 +253,6 @@ struct ImageGalleryViewer: View {
Spacer()
// Page counter (if multiple images)
if state.images.count > 1 {
Text("\(currentPage + 1) / \(state.images.count)")
.font(.system(size: 15, weight: .medium))
@@ -215,15 +262,13 @@ struct ImageGalleryViewer: View {
}
.padding(.horizontal, 4)
.padding(.vertical, 8)
// Extend dark background up into the notch / Dynamic Island safe area
.background(Color.black.opacity(0.5).ignoresSafeArea(edges: .top))
}
// MARK: - Bottom Bar (Caption + Share/Save)
// MARK: - Bottom Bar
private var bottomBar: some View {
VStack(spacing: 0) {
// Caption text (Android: AppleEmojiText, 15sp, 4 lines max)
if let caption = currentInfo?.caption, !caption.isEmpty {
Text(caption)
.font(.system(size: 15))
@@ -235,7 +280,6 @@ struct ImageGalleryViewer: View {
.background(Color.black.opacity(0.5))
}
// Action buttons
HStack(spacing: 32) {
Button { shareCurrentImage() } label: {
Image(systemName: "square.and.arrow.up")
@@ -255,7 +299,6 @@ struct ImageGalleryViewer: View {
}
.padding(.horizontal, 24)
.padding(.bottom, 8)
// Extend dark background down into the home indicator safe area
.background(Color.black.opacity(0.5).ignoresSafeArea(edges: .bottom))
}
}
@@ -265,18 +308,42 @@ struct ImageGalleryViewer: View {
private func navigateEdgeTap(direction: Int) {
let target = currentPage + direction
guard target >= 0, target < state.images.count else { return }
// Android: instant page switch with short fade (120ms)
currentPage = target
}
// MARK: - Smooth Dismiss (Android: 200ms fade-out)
// MARK: - Dismiss
private func smoothDismiss() {
/// Unified dismiss: hero collapse when not zoomed, fade when zoomed.
private func dismiss() {
if currentZoomScale > 1.05 {
fadeDismiss()
} else {
heroDismiss()
}
}
/// Hero collapse back to source frame.
private func heroDismiss() {
guard !isDismissing else { return }
isDismissing = true
Task {
withAnimation(heroAnimation.speed(1.2)) {
dragOffset = .zero
isExpanded = false
}
try? await Task.sleep(for: .seconds(0.35))
onDismiss()
}
}
/// Fallback fade dismiss when zoomed.
private func fadeDismiss() {
guard !isDismissing else { return }
isDismissing = true
withAnimation(.easeOut(duration: 0.2)) {
presentationAlpha = 0
isExpanded = false
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) {
@@ -321,7 +388,6 @@ struct ImageGalleryViewer: View {
// MARK: - Prefetch
private func prefetchAdjacentImages(around index: Int) {
// Android: prefetches ±2 images from current page
for offset in [-2, -1, 1, 2] {
let i = index + offset
guard i >= 0, i < state.images.count else { continue }
@@ -334,5 +400,5 @@ struct ImageGalleryViewer: View {
}
}
}
}

View File

@@ -9,7 +9,7 @@ final class MessageCellActions {
var onForward: (ChatMessage) -> Void = { _ in }
var onDelete: (ChatMessage) -> Void = { _ in }
var onCopy: (String) -> Void = { _ in }
var onImageTap: (String) -> Void = { _ in }
var onImageTap: (String, CGRect) -> Void = { _, _ in }
var onScrollToMessage: (String) -> Void = { _ in }
var onRetry: (ChatMessage) -> Void = { _ in }
var onRemove: (ChatMessage) -> Void = { _ in }

View File

@@ -197,7 +197,7 @@ struct MessageCellView: View, Equatable {
attachments: imageAttachments,
outgoing: outgoing,
maxWidth: imageContentWidth,
onImageTap: { attId in actions.onImageTap(attId) }
onImageTap: { attId in actions.onImageTap(attId, .zero) }
)
.padding(.horizontal, 6)
.padding(.top, 4)
@@ -250,9 +250,10 @@ struct MessageCellView: View, Equatable {
items: contextMenuItems(for: message),
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
isOutgoing: outgoing,
onTap: !imageAttachments.isEmpty ? { _ in
onTap: !imageAttachments.isEmpty ? { _, overlayView in
if let firstId = imageAttachments.first?.id {
actions.onImageTap(firstId)
let frame = overlayView?.convert(overlayView?.bounds ?? .zero, to: nil) ?? .zero
actions.onImageTap(firstId, frame)
}
} : nil
)
@@ -350,7 +351,7 @@ struct MessageCellView: View, Equatable {
items: contextMenuItems(for: message),
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
isOutgoing: outgoing,
onTap: !attachments.isEmpty ? { tapLocation in
onTap: !attachments.isEmpty ? { tapLocation, overlayView in
if !imageAttachments.isEmpty {
let tappedId = imageAttachments.count == 1
? imageAttachments[0].id
@@ -360,7 +361,8 @@ struct MessageCellView: View, Equatable {
maxWidth: maxBubbleWidth
)
if AttachmentCache.shared.loadImage(forAttachmentId: tappedId) != nil {
actions.onImageTap(tappedId)
let frame = overlayView?.convert(overlayView?.bounds ?? .zero, to: nil) ?? .zero
actions.onImageTap(tappedId, frame)
} else {
NotificationCenter.default.post(
name: .triggerAttachmentDownload, object: tappedId

View File

@@ -1404,8 +1404,11 @@ final class NativeMessageCell: UICollectionViewCell {
}
let attachment = photoAttachments[sender.tag]
let imageView = photoTileImageViews[sender.tag]
let sourceFrame = imageView.convert(imageView.bounds, to: nil)
if AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) != nil {
actions.onImageTap(attachment.id)
actions.onImageTap(attachment.id, sourceFrame)
return
}
@@ -1422,7 +1425,7 @@ final class NativeMessageCell: UICollectionViewCell {
return
}
if loaded != nil {
actions.onImageTap(attachment.id)
actions.onImageTap(attachment.id, sourceFrame)
} else {
self.downloadPhotoAttachment(attachment: attachment, message: message)
}

View File

@@ -10,15 +10,11 @@ struct ZoomableImagePage: View {
let attachmentId: String
let onDismiss: () -> Void
let onDismissProgress: (CGFloat) -> Void
let onDismissCancel: () -> Void
@Binding var showControls: Bool
@Binding var currentScale: CGFloat
let onEdgeTap: ((Int) -> Void)?
@State private var image: UIImage?
/// Vertical drag offset for dismiss gesture (SwiftUI DragGesture).
@State private var dismissDragOffset: CGFloat = 0
@State private var zoomScale: CGFloat = 1.0
@State private var zoomOffset: CGSize = .zero
@@ -34,7 +30,7 @@ struct ZoomableImagePage: View {
.scaledToFit()
.scaleEffect(effectiveScale)
.offset(x: effectiveScale > 1.05 ? zoomOffset.width : 0,
y: (effectiveScale > 1.05 ? zoomOffset.height : 0) + dismissDragOffset)
y: effectiveScale > 1.05 ? zoomOffset.height : 0)
// Expand hit-test area to full screen scaleEffect is visual-only
// and doesn't grow the Image's gesture frame. Without this,
// double-tap to zoom out doesn't work on zoomed-in edges.
@@ -94,13 +90,7 @@ struct ZoomableImagePage: View {
}
: nil
)
// Dismiss drag (vertical swipe when not zoomed)
// simultaneousGesture so it coexists with TabView's page swipe.
// The 2.0× vertical ratio in dismissDragGesture prevents
// horizontal swipes from triggering dismiss.
.simultaneousGesture(
zoomScale <= 1.05 ? dismissDragGesture : nil
)
// Dismiss drag handled by HeroPanGesture on ImageGalleryViewer level.
} else {
placeholder
}
@@ -121,35 +111,6 @@ struct ZoomableImagePage: View {
}
}
/// Vertical drag-to-dismiss gesture.
/// Uses minimumDistance:40 to give TabView's page swipe a head start.
private var dismissDragGesture: some Gesture {
DragGesture(minimumDistance: 40, coordinateSpace: .local)
.onChanged { value in
let dy = abs(value.translation.height)
let dx = abs(value.translation.width)
// Only vertical-dominant drags trigger dismiss
guard dy > dx * 2.0 else { return }
dismissDragOffset = value.translation.height
let progress = min(abs(dismissDragOffset) / 300, 1.0)
onDismissProgress(progress)
}
.onEnded { value in
let velocityY = abs(value.predictedEndTranslation.height - value.translation.height)
if abs(dismissDragOffset) > 100 || velocityY > 500 {
// Dismiss keep offset so photo doesn't jump back before fade-out
onDismiss()
} else {
// Snap back
withAnimation(.easeOut(duration: 0.25)) {
dismissDragOffset = 0
}
onDismissCancel()
}
}
}
// MARK: - Placeholder
private var placeholder: some View {