Push-уведомления: Telegram-parity in-app баннер, threadIdentifier группировка и letter-avatar в NSE
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user