Files
mobile-ios/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift

2238 lines
114 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import UIKit
import UserNotifications
/// Measures the composer height so the scroll can reserve bottom space.
private struct ComposerHeightKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
/// Reserves space at the bottom of the scroll content for the composer.
/// Both iOS versions: list extends under the composer (overlay pattern),
/// spacer prevents messages from going behind the composer.
/// iOS < 26: composerHeight is measured from UIKit view.bounds.height
/// (includes safe area when keyboard hidden, excludes when open).
private struct KeyboardSpacer: View {
let composerHeight: CGFloat
var body: some View {
Color.clear.frame(height: max(composerHeight, 0))
}
}
struct ChatDetailView: View {
let route: ChatRoute
var onPresentedChange: ((Bool) -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel: ChatDetailViewModel
init(route: ChatRoute, onPresentedChange: ((Bool) -> Void)? = nil) {
self.route = route
self.onPresentedChange = onPresentedChange
_viewModel = StateObject(wrappedValue: ChatDetailViewModel(dialogKey: route.publicKey))
}
@State private var messageText = ""
@State private var isMultilineInput = false
@State private var textInputHeight: CGFloat = 36
@State private var sendError: String?
@State private var isViewActive = false
// markReadTask removed read receipts no longer sent from .onChange(of: messages.count)
@State private var isInputFocused = false
@State private var isAtBottom = true
@State private var composerHeight: CGFloat = 56
/// Nav bar height from UIKit (Bridge pattern). Used as padding inside scroll
/// content to prevent messages from going behind nav bar on iOS < 26.
@State private var topSafeArea: CGFloat = 0
@State private var shouldScrollOnNextMessage = false
/// Captured on chat open ID of the first unread incoming message (for separator).
@State private var firstUnreadMessageId: String?
@State private var isSendingAvatar = false
@State private var showAttachmentPanel = false
@State private var showNoAvatarAlert = false
@State private var pendingAttachments: [PendingAttachment] = []
@State private var showOpponentProfile = false
@State private var replyingToMessage: ChatMessage?
@State private var showForwardPicker = false
@State private var forwardingMessage: ChatMessage?
@State private var messageToDelete: ChatMessage?
// Image viewer is presented via ImageViewerPresenter (UIKit overFullScreen),
// not via SwiftUI fullScreenCover, to avoid bottom-sheet slide-up animation.
/// ID of message to scroll to (set when tapping a reply quote).
@State private var scrollToMessageId: String?
/// ID of message currently highlighted after scroll-to-reply navigation.
@State private var highlightedMessageId: String?
/// Stable callback reference for message cell interactions.
/// Class ref pointer is stable across parent re-renders cells not marked dirty.
@State private var cellActions = MessageCellActions()
/// Cached at view init never changes during a session. Avoids @Observable
/// observation on SessionManager that would re-render all cells on any state change.
private let currentPublicKey: String = SessionManager.shared.currentPublicKey
private var dialog: Dialog? {
DialogRepository.shared.dialogs[route.publicKey]
}
private var messages: [ChatMessage] {
viewModel.messages
}
private var isTyping: Bool {
viewModel.isTyping
}
private var titleText: String {
if route.isSavedMessages { return "Saved Messages" }
if let dialog, !dialog.opponentTitle.isEmpty { return dialog.opponentTitle }
if !route.title.isEmpty { return route.title }
if let dialog, !dialog.opponentUsername.isEmpty { return "@\(dialog.opponentUsername)" }
if !route.username.isEmpty { return "@\(route.username)" }
return String(route.publicKey.prefix(12))
}
private var effectiveVerified: Int {
if let dialog { return dialog.effectiveVerified }
if route.verified > 0 { return route.verified }
return 0
}
private var subtitleText: String {
if route.isSavedMessages { return "" }
// Desktop parity: system accounts show "official account" instead of online/offline
if route.isSystemAccount { return "official account" }
if isTyping { return "typing..." }
if let dialog, dialog.isOnline { return "online" }
return "offline"
}
private var trimmedMessage: String {
messageText.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var canSend: Bool {
!trimmedMessage.isEmpty || !pendingAttachments.isEmpty
}
private var shouldShowSendButton: Bool {
!messageText.isEmpty || !pendingAttachments.isEmpty
}
private var sendButtonProgress: CGFloat {
shouldShowSendButton ? 1 : 0
}
private var micButtonProgress: CGFloat {
shouldShowSendButton ? 0 : 1
}
private var sendButtonWidth: CGFloat { 38 }
private var sendButtonHeight: CGFloat { 36 }
private var composerTrailingPadding: CGFloat { 16 }
private var composerAnimation: Animation {
.spring(response: 0.28, dampingFraction: 0.9)
}
private var messagesTopInset: CGFloat { 6 }
/// Nav bar padding for inverted scroll (iOS < 26).
/// UIKit transform flips safe areas this adds correct top padding inside scroll content.
/// On iOS 26+: 0 (SwiftUI handles safe areas natively).
private var navBarPadding: CGFloat {
if #available(iOS 26, *) { return 0 }
return topSafeArea
}
/// Scroll-to-bottom button padding above bottom edge (above composer).
private var scrollToBottomPadding: CGFloat {
composerHeight + 4
}
/// Scroll-to-bottom button alignment within scroll overlay.
/// iOS < 26: UIKit transform flips the view .top becomes visual bottom.
private var scrollToBottomAlignment: Alignment {
if #available(iOS 26, *) { return .bottom }
return .top
}
/// Padding edge for scroll-to-bottom button.
/// iOS < 26: .top = visual bottom after UIKit flip.
private var scrollToBottomPaddingEdge: Edge.Set {
if #available(iOS 26, *) { return .bottom }
return .top
}
private static let scrollBottomAnchorId = "chat_detail_bottom_anchor"
private var maxBubbleWidth: CGFloat {
max(min(UIScreen.main.bounds.width * 0.72, 380), 140)
}
/// Visual chat content: messages list + gradient overlays + background.
/// NO composer overlay on iOS < 26 composer is a separate UIHostingController.
/// On iOS < 26 the entire listController.view is UIKit-flipped (transform y: -1),
/// so gradients/backgrounds use CounterUIKitFlipModifier to stay screen-relative.
@ViewBuilder
private var chatArea: some View {
ZStack {
messagesList(maxBubbleWidth: maxBubbleWidth)
}
.overlay {
chatEdgeGradients
.modifier(CounterUIKitFlipModifier())
}
// FPS overlay uncomment for performance testing:
// .overlay { FPSOverlayView() }
.background {
ZStack {
RosettaColors.Adaptive.background
tiledChatBackground
.modifier(CounterUIKitFlipModifier())
}
.ignoresSafeArea()
}
}
@ViewBuilder
private var content: some View {
let _ = PerformanceLogger.shared.track("chatDetail.bodyEval")
// iOS < 26: KeyboardSyncedContainer uses TWO UIHostingControllers.
// Composer pinned to keyboardLayoutGuide (UIKit moves position Y).
// List bottom pinned to composer top (shrinks when composer moves up).
// Zero SwiftUI relayout jump Telegram-style sync.
// iOS 26+: SwiftUI handles keyboard natively overlay approach.
Group {
if #available(iOS 26, *) {
chatArea
.overlay {
if !route.isSystemAccount {
ComposerOverlay(
composer: composer,
composerHeight: $composerHeight
)
}
}
.onPreferenceChange(ComposerHeightKey.self) { newHeight in
composerHeight = newHeight
}
} else {
KeyboardSyncedContainer(
content: { chatArea },
composer: {
if !route.isSystemAccount { composer }
},
onComposerHeightChange: { height in
composerHeight = height
},
onTopSafeAreaChange: { inset in
topSafeArea = inset
}
)
}
}
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.enableSwipeBack()
.modifier(ChatDetailNavBarStyleModifier())
.toolbar { chatDetailToolbar }
.toolbar(.hidden, for: .tabBar)
.task {
isViewActive = true
// Wire up cell action callbacks (once, stable class ref).
cellActions.onReply = { [self] msg in replyingToMessage = msg; isInputFocused = true }
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.onScrollToMessage = { [self] msgId in scrollToMessageId = msgId }
cellActions.onRetry = { [self] msg in retryMessage(msg) }
cellActions.onRemove = { [self] msg in removeMessage(msg) }
// Capture first unread incoming message BEFORE marking as read.
if firstUnreadMessageId == nil {
firstUnreadMessageId = messages.first(where: {
!$0.isRead && $0.fromPublicKey != currentPublicKey
})?.id
}
// Desktop parity: restore draft text from DraftManager.
let draft = DraftManager.shared.getDraft(for: route.publicKey)
if !draft.isEmpty {
messageText = draft
}
// Suppress notifications & clear badge immediately (no 600ms delay).
// setDialogActive only touches MessageRepository.activeDialogs (Set),
// does NOT mutate DialogRepository, so ForEach won't rebuild.
MessageRepository.shared.setDialogActive(route.publicKey, isActive: true)
clearDeliveredNotifications(for: route.publicKey)
// Android parity: mark messages as read in DB IMMEDIATELY (no delay).
// This prevents reconcileUnreadCounts() from re-inflating badge
// if it runs during the 600ms navigation delay.
MessageRepository.shared.markIncomingAsRead(
opponentKey: route.publicKey, myPublicKey: currentPublicKey
)
// Request user info (non-mutating, won't trigger list rebuild)
requestUserInfoIfNeeded()
// Delay DialogRepository mutations to let navigation transition complete.
// Without this, DialogRepository update rebuilds ChatListView's ForEach
// mid-navigation, recreating the NavigationLink and canceling the push.
try? await Task.sleep(for: .milliseconds(600))
guard isViewActive else { return }
activateDialog()
markDialogAsRead()
// Desktop parity: skip online subscription and user info fetch for system accounts
if !route.isSystemAccount {
// Subscribe to opponent's online status (Android parity) only after settled
SessionManager.shared.subscribeToOnlineStatus(publicKey: route.publicKey)
// Desktop parity: force-refresh user info (incl. online status) on chat open.
// PacketSearch (0x03) returns current online state, supplementing 0x05 subscription.
if !route.isSavedMessages {
SessionManager.shared.forceRefreshUserInfo(publicKey: route.publicKey)
}
}
}
.onDisappear {
isViewActive = false
firstUnreadMessageId = nil
// Android parity: mark all messages as read when leaving dialog.
// Android's unmount callback does SQL UPDATE messages SET read = 1.
// Don't re-send read receipt it was already sent during the session.
DialogRepository.shared.markAsRead(opponentKey: route.publicKey)
MessageRepository.shared.markIncomingAsRead(
opponentKey: route.publicKey, myPublicKey: currentPublicKey
)
MessageRepository.shared.setDialogActive(route.publicKey, isActive: false)
// Desktop parity: save draft text on chat close.
DraftManager.shared.saveDraft(for: route.publicKey, text: messageText)
}
}
var body: some View {
content
.navigationDestination(isPresented: $showOpponentProfile) {
OpponentProfileView(route: route)
}
.sheet(isPresented: $showForwardPicker) {
ForwardChatPickerView { targetRoute in
showForwardPicker = false
guard let message = forwardingMessage else { return }
forwardingMessage = nil
forwardMessage(message, to: targetRoute)
}
}
// Image viewer: presented via ImageViewerPresenter (UIKit overFullScreen + crossDissolve).
// No .fullScreenCover avoids the default bottom-sheet slide-up animation.
.alert("Delete Message", isPresented: Binding(
get: { messageToDelete != nil },
set: { if !$0 { messageToDelete = nil } }
)) {
Button("Delete", role: .destructive) {
if let message = messageToDelete {
removeMessage(message)
messageToDelete = nil
}
}
Button("Cancel", role: .cancel) {
messageToDelete = nil
}
} message: {
Text("Are you sure you want to delete this message? This action cannot be undone.")
}
.alert("No Avatar", isPresented: $showNoAvatarAlert) {
Button("OK", role: .cancel) {}
} message: {
Text("Set a profile photo in Settings to share it with contacts.")
}
.sheet(isPresented: $showAttachmentPanel) {
AttachmentPanelView(
onSend: { attachments, caption in
// Pre-fill caption as message text (sent alongside attachments)
let trimmedCaption = caption.trimmingCharacters(in: .whitespaces)
if !trimmedCaption.isEmpty {
messageText = trimmedCaption
}
handleAttachmentsSend(attachments)
},
onSendAvatar: {
sendAvatarToChat()
},
hasAvatar: AvatarRepository.shared.loadAvatar(publicKey: currentPublicKey) != nil,
onSetAvatar: {
showNoAvatarAlert = true
}
)
}
}
}
// MARK: - Toolbar Content (observation-isolated)
/// Reads `DialogRepository` in its own observation scope for title/subtitle/verified.
/// Dialog mutations (from ANY chat) no longer cascade to ChatDetailView body,
/// preventing all visible message cells from re-evaluating.
private struct ChatDetailPrincipal: View {
let route: ChatRoute
@ObservedObject var viewModel: ChatDetailViewModel
private var dialog: Dialog? {
DialogRepository.shared.dialogs[route.publicKey]
}
private var badgeSpacing: CGFloat {
if #available(iOS 26, *) { return 3 } else { return 4 }
}
private var badgeSize: CGFloat {
if #available(iOS 26, *) { return 12 } else { return 14 }
}
private var titleText: String {
if route.isSavedMessages { return "Saved Messages" }
if let dialog, !dialog.opponentTitle.isEmpty { return dialog.opponentTitle }
if !route.title.isEmpty { return route.title }
if let dialog, !dialog.opponentUsername.isEmpty { return "@\(dialog.opponentUsername)" }
if !route.username.isEmpty { return "@\(route.username)" }
return String(route.publicKey.prefix(12))
}
private var effectiveVerified: Int {
if let dialog { return dialog.effectiveVerified }
if route.verified > 0 { return route.verified }
return 0
}
private var subtitleText: String {
if route.isSavedMessages { return "" }
if route.isSystemAccount { return "official account" }
if viewModel.isTyping { return "typing..." }
if let dialog, dialog.isOnline { return "online" }
return "offline"
}
private var subtitleColor: Color {
if viewModel.isTyping { return RosettaColors.primaryBlue }
if dialog?.isOnline == true { return RosettaColors.online }
return RosettaColors.Adaptive.textSecondary
}
var body: some View {
VStack(spacing: 1) {
HStack(spacing: badgeSpacing) {
Text(titleText)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
if !route.isSavedMessages && effectiveVerified > 0 {
VerifiedBadge(verified: effectiveVerified, size: badgeSize)
}
}
if !subtitleText.isEmpty {
Text(subtitleText)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(subtitleColor)
.lineLimit(1)
}
}
}
}
/// Reads `DialogRepository` and `AvatarRepository` in its own observation scope
/// for avatar initials/color/image. Isolated from ChatDetailView body.
private struct ChatDetailToolbarAvatar: View {
let route: ChatRoute
let size: CGFloat
private var dialog: Dialog? {
DialogRepository.shared.dialogs[route.publicKey]
}
private var titleText: String {
if route.isSavedMessages { return "Saved Messages" }
if let dialog, !dialog.opponentTitle.isEmpty { return dialog.opponentTitle }
if !route.title.isEmpty { return route.title }
if let dialog, !dialog.opponentUsername.isEmpty { return "@\(dialog.opponentUsername)" }
if !route.username.isEmpty { return "@\(route.username)" }
return String(route.publicKey.prefix(12))
}
var body: some View {
let _ = AvatarRepository.shared.avatarVersion // observation dependency for reactive updates
let avatar = AvatarRepository.shared.loadAvatar(publicKey: route.publicKey)
let initials = route.isSavedMessages ? "S" : RosettaColors.initials(name: titleText, publicKey: route.publicKey)
let colorIndex = RosettaColors.avatarColorIndex(for: titleText, publicKey: route.publicKey)
AvatarView(
initials: initials,
colorIndex: colorIndex,
size: size,
isOnline: false,
isSavedMessages: route.isSavedMessages,
image: avatar
)
}
}
private extension ChatDetailView {
// MARK: - Toolbar (как в ChatListView)
@ToolbarContentBuilder
var chatDetailToolbar: some ToolbarContent {
if #available(iOS 26, *) {
// iOS 26+ original compact sizes with .glassEffect()
ToolbarItem(placement: .navigationBarLeading) {
Button { dismiss() } label: {
TelegramVectorIcon(
pathData: TelegramIconPath.backChevron,
viewBox: CGSize(width: 11, height: 20),
color: .white
)
.frame(width: 11, height: 20)
.frame(width: 36, height: 36)
.contentShape(Circle())
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
}
.buttonStyle(.plain)
.accessibilityLabel("Back")
}
ToolbarItem(placement: .principal) {
Button { openProfile() } label: {
ChatDetailPrincipal(route: route, viewModel: viewModel)
.padding(.horizontal, 12)
.frame(minWidth: 120)
.frame(height: 44)
.contentShape(Capsule())
.background {
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
}
}
.buttonStyle(.plain)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button { openProfile() } label: {
ChatDetailToolbarAvatar(route: route, size: 35)
.frame(width: 36, height: 36)
.contentShape(Circle())
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
}
.buttonStyle(.plain)
}
} else {
// iOS < 26 capsule back button, larger avatar, .thinMaterial
ToolbarItem(placement: .navigationBarLeading) {
Button { dismiss() } label: { backCapsuleButtonLabel }
.buttonStyle(.plain)
.accessibilityLabel("Back")
}
ToolbarItem(placement: .principal) {
Button { openProfile() } label: {
ChatDetailPrincipal(route: route, viewModel: viewModel)
.padding(.horizontal, 16)
.frame(minWidth: 120)
.frame(height: 44)
.contentShape(Capsule())
.background {
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
}
}
.buttonStyle(.plain)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button { openProfile() } label: {
ChatDetailToolbarAvatar(route: route, size: 38)
.frame(width: 44, height: 44)
.contentShape(Circle())
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
}
.buttonStyle(.plain)
}
}
}
private var backCapsuleButtonLabel: some View {
TelegramVectorIcon(
pathData: TelegramIconPath.backChevron,
viewBox: CGSize(width: 11, height: 20),
color: .white
)
.frame(width: 11, height: 20)
.frame(width: 36, height: 36)
.frame(height: 44)
.padding(.horizontal, 4)
.contentShape(Capsule())
.background {
glass(shape: .capsule, strokeOpacity: 0.22, strokeColor: .white)
}
}
// MARK: - Existing helpers / UI
var avatarInitials: String {
if route.isSavedMessages { return "S" }
return RosettaColors.initials(name: titleText, publicKey: route.publicKey)
}
var avatarColorIndex: Int {
RosettaColors.avatarColorIndex(for: titleText, publicKey: route.publicKey)
}
/// Avatar image for the opponent. System accounts return a bundled static image.
var opponentAvatar: UIImage? {
AvatarRepository.shared.loadAvatar(publicKey: route.publicKey)
}
// MARK: - Edge Gradients (Telegram-style)
/// Top: native SwiftUI Material blur with gradient mask blurs content behind it.
@ViewBuilder
var chatEdgeGradients: some View {
if #available(iOS 26, *) {
VStack(spacing: 0) {
// Top: dark gradient behind nav bar (same style as iOS < 26).
LinearGradient(
stops: [
.init(color: Color.black.opacity(0.85), location: 0.0),
.init(color: Color.black.opacity(0.75), location: 0.2),
.init(color: Color.black.opacity(0.55), location: 0.4),
.init(color: Color.black.opacity(0.3), location: 0.6),
.init(color: Color.black.opacity(0.12), location: 0.78),
.init(color: Color.black.opacity(0.0), location: 1.0),
],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 100)
Spacer()
// Bottom: dark gradient for home indicator area.
LinearGradient(
stops: [
.init(color: Color.black.opacity(0.0), location: 0.0),
.init(color: Color.black.opacity(0.3), location: 0.3),
.init(color: Color.black.opacity(0.65), location: 0.6),
.init(color: Color.black.opacity(0.85), location: 1.0),
],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 54)
}
.ignoresSafeArea()
.allowsHitTesting(false)
} else {
VStack(spacing: 0) {
// Telegram-style: dark gradient that smoothly fades content into
// the dark background behind the nav bar pills.
// NOT a material blur Telegram uses dark overlay, not light material.
LinearGradient(
stops: [
.init(color: Color.black.opacity(0.85), location: 0.0),
.init(color: Color.black.opacity(0.75), location: 0.2),
.init(color: Color.black.opacity(0.55), location: 0.4),
.init(color: Color.black.opacity(0.3), location: 0.6),
.init(color: Color.black.opacity(0.12), location: 0.78),
.init(color: Color.black.opacity(0.0), location: 1.0),
],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 90)
Spacer()
// Bottom: dark gradient for home indicator area below composer.
LinearGradient(
stops: [
.init(color: Color.black.opacity(0.0), location: 0.0),
.init(color: Color.black.opacity(0.55), location: 0.35),
.init(color: Color.black.opacity(0.85), location: 1.0),
],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 44)
}
.ignoresSafeArea()
.allowsHitTesting(false)
}
}
/// Cached tiled pattern color computed once, reused across renders
private static let cachedTiledColor: Color? = {
guard let uiImage = UIImage(named: "ChatBackground"),
let cgImage = uiImage.cgImage else { return nil }
let tileWidth: CGFloat = 200
let scaleFactor = uiImage.size.width / tileWidth
let scaledImage = UIImage(
cgImage: cgImage,
scale: uiImage.scale * scaleFactor,
orientation: .up
)
return Color(uiColor: UIColor(patternImage: scaledImage))
}()
/// Tiled chat background with properly scaled tiles (200pt wide)
private var tiledChatBackground: some View {
Group {
if let color = Self.cachedTiledColor {
color.opacity(0.18)
} else {
Color.clear
}
}
}
// MARK: - Messages
@ViewBuilder
func messagesList(maxBubbleWidth: CGFloat) -> some View {
if viewModel.isLoading && messages.isEmpty {
// Android parity: skeleton placeholder while loading from DB
ChatDetailSkeletonView(maxBubbleWidth: maxBubbleWidth)
} else if messages.isEmpty {
emptyStateView
} else {
messagesScrollView(maxBubbleWidth: maxBubbleWidth)
}
}
private var emptyStateView: some View {
VStack(spacing: 0) {
Spacer()
VStack(spacing: 16) {
AvatarView(
initials: avatarInitials,
colorIndex: avatarColorIndex,
size: 80,
isOnline: dialog?.isOnline ?? false,
isSavedMessages: route.isSavedMessages,
image: opponentAvatar
)
VStack(spacing: 4) {
Text(titleText)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
if !route.isSavedMessages {
Text(subtitleText)
.font(.system(size: 14, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
}
}
Text(route.isSavedMessages
? "Save messages here for quick access"
: "No messages yet")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.7))
.padding(.top, 4)
}
Spacer()
// Reserve space for compositor so content centers above it.
Color.clear.frame(height: composerHeight)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
.onTapGesture { isInputFocused = false }
}
@ViewBuilder
private func messagesScrollView(maxBubbleWidth: CGFloat) -> some View {
ScrollViewReader { proxy in
let scroll = ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
// Anchor at VStack START after flip = visual BOTTOM (newest edge).
// scrollTo(anchor, .top) places this at viewport top = visual bottom.
Color.clear
.frame(height: 4)
.id(Self.scrollBottomAnchorId)
// Spacer for composer + keyboard OUTSIDE LazyVStack.
// In inverted scroll, spacer at START pushes messages away from
// offset=0. When spacer grows (keyboard opens), messages move up
// visually no scrollTo needed, no defaultScrollAnchor needed.
KeyboardSpacer(composerHeight: composerHeight)
// LazyVStack: only visible cells are loaded.
LazyVStack(spacing: 0) {
// Sentinel for viewport-based scroll tracking.
// Must be inside LazyVStack regular VStack doesn't
// fire onAppear/onDisappear on scroll.
Color.clear
.frame(height: 1)
.onAppear { withAnimation(.easeOut(duration: 0.2)) { isAtBottom = true } }
.onDisappear { withAnimation(.easeOut(duration: 0.2)) { isAtBottom = false } }
// PERF: VStack wrapper ensures each ForEach element produces
// exactly 1 view SwiftUI uses FAST PATH (O(1) diffing).
// Without it: conditional unreadSeparator makes element count
// variable SLOW PATH (O(n) full scan on every update).
ForEach(messages.reversed()) { message in
VStack(spacing: 0) {
let index = messageIndex(for: message.id)
let position = bubblePosition(for: index)
MessageCellView(
message: message,
maxBubbleWidth: maxBubbleWidth,
position: position,
currentPublicKey: currentPublicKey,
highlightedMessageId: highlightedMessageId,
isSavedMessages: route.isSavedMessages,
isSystemAccount: route.isSystemAccount,
opponentPublicKey: route.publicKey,
opponentTitle: route.title,
opponentUsername: route.username,
actions: cellActions
)
.equatable()
.scaleEffect(x: 1, y: -1) // flip each row back to normal
// Unread Messages separator (Telegram style).
if message.id == firstUnreadMessageId {
unreadSeparator
.scaleEffect(x: 1, y: -1)
}
}
}
// PAGINATION TRIGGER (end of LazyVStack = visual top of chat).
// When sentinel appears, load older messages from SQLite.
if viewModel.hasMoreMessages {
ProgressView()
.frame(height: 40)
.frame(maxWidth: .infinity)
.scaleEffect(x: 1, y: -1)
.onAppear {
Task { await viewModel.loadMore() }
}
}
}
}
.padding(.horizontal, 10)
// visual top (near nav bar): messagesTopInset + navBarPadding.
// navBarPadding = topSafeArea on iOS < 26 (UIKit flip inverts safe areas).
.padding(.bottom, messagesTopInset + navBarPadding)
}
// iOS 26: disable default scroll edge blur in inverted scroll the top+bottom
// effects overlap and blur the entire screen.
.modifier(DisableScrollEdgeEffectModifier())
// iOS 26+: SwiftUI scaleEffect for inversion.
// iOS < 26: UIKit transform on listController.view handles inversion
// no SwiftUI scaleEffect needed (avoids center-shift jump on frame resize).
.modifier(ScrollInversionModifier())
.scrollDismissesKeyboard(.interactively)
.onTapGesture { isInputFocused = false }
.onAppear {
// In inverted scroll, offset 0 IS the visual bottom no scroll needed.
// Safety scroll for edge cases (e.g., view recycling).
DispatchQueue.main.async { scrollToBottom(proxy: proxy, animated: false) }
}
.onChange(of: messages.last?.id) { _, _ in
let lastIsOutgoing = messages.last?.isFromMe(myPublicKey: currentPublicKey) == true
if shouldScrollOnNextMessage || lastIsOutgoing || isAtBottom {
DispatchQueue.main.async {
scrollToBottom(proxy: proxy, animated: true)
}
shouldScrollOnNextMessage = false
}
if isViewActive && !lastIsOutgoing
&& !route.isSavedMessages && !route.isSystemAccount {
markDialogAsRead()
}
}
// Scroll-to-reply: navigate to the original message and highlight it briefly.
.onChange(of: scrollToMessageId) { _, targetId in
guard let targetId else { return }
scrollToMessageId = nil
withAnimation(.easeInOut(duration: 0.3)) {
proxy.scrollTo(targetId, anchor: .center)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
withAnimation(.easeIn(duration: 0.2)) {
highlightedMessageId = targetId
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
withAnimation(.easeOut(duration: 0.5)) {
highlightedMessageId = nil
}
}
}
}
// No keyboard scroll handlers needed inverted scroll keeps bottom anchored.
scroll
.scrollIndicators(.hidden)
.overlay(alignment: scrollToBottomAlignment) {
scrollToBottomButton(proxy: proxy)
.modifier(CounterUIKitFlipModifier())
.padding(scrollToBottomPaddingEdge, scrollToBottomPadding)
}
}
}
@ViewBuilder
private func scrollToBottomButton(proxy: ScrollViewProxy) -> some View {
// Positioning container always present, no transition on it.
// Only the button itself animates in/out.
HStack {
Spacer()
if !isAtBottom {
Button {
scrollToBottom(proxy: proxy, animated: true)
} label: {
TelegramVectorIcon(
pathData: TelegramIconPath.chevronDown,
viewBox: CGSize(width: 22, height: 12),
color: .white
)
.frame(width: 14, height: 8)
.frame(width: 42, height: 42)
.contentShape(Circle())
.background {
glass(shape: .circle, strokeOpacity: 0.18)
}
}
.buttonStyle(ChatDetailGlassPressButtonStyle())
.transition(.scale(scale: 0.01, anchor: .center).combined(with: .opacity))
}
}
.padding(.trailing, composerTrailingPadding)
.allowsHitTesting(!isAtBottom)
}
// Message row rendering extracted to MessageCellView (Equatable, .equatable() modifier).
// Remaining methods: messageRow, textOnlyBubble, attachmentBubble, forwardedMessageBubble,
// timestampOverlay, mediaTimestampOverlay, bubbleBackground, deliveryIndicator, errorMenu,
// replyQuoteView, parsedMarkdown, messageTime, parseReplyBlob, senderDisplayName,
// cachedBlurHash, contextMenuReadStatus, bubbleActions, collageAttachmentId, all static caches.
// See MessageCellView.swift.
// MARK: - Unread Separator
private var unreadSeparator: some View {
Text("Unread Messages")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.white.opacity(0.7))
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(Color.white.opacity(0.08))
.padding(.horizontal, -10) // compensate scroll content padding
.padding(.top, 6)
.padding(.bottom, 2)
}
// MARK: - Composer
var composer: some View {
VStack(spacing: 6) {
// Attachment preview strip shows selected images/files before send
if !pendingAttachments.isEmpty {
AttachmentPreviewStrip(pendingAttachments: $pendingAttachments)
}
if let sendError {
Text(sendError)
.font(.system(size: 12))
.foregroundStyle(RosettaColors.error)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
}
HStack(alignment: .bottom, spacing: 0) {
// Desktop parity: paperclip opens attachment panel (photo gallery + file picker).
Button {
showAttachmentPanel = true
} label: {
TelegramVectorIcon(
pathData: TelegramIconPath.paperclip,
viewBox: CGSize(width: 21, height: 24),
color: Color.white
)
.frame(width: 21, height: 24)
.frame(width: 42, height: 42)
.contentShape(Circle())
.background { glass(shape: .circle, strokeOpacity: 0.18) }
}
.accessibilityLabel("Attach")
.buttonStyle(ChatDetailGlassPressButtonStyle())
VStack(spacing: 0) {
// Reply preview bar inside the glass container
if let replyMessage = replyingToMessage {
replyBar(for: replyMessage)
}
HStack(alignment: .bottom, spacing: 0) {
ChatTextInput(
text: $messageText,
isFocused: $isInputFocused,
onKeyboardHeightChange: { height in
KeyboardTracker.shared.updateFromKVO(keyboardHeight: height)
},
onUserTextInsertion: handleComposerUserTyping,
onMultilineChange: { multiline in
#if DEBUG
print("📐 onMultilineChange callback: \(multiline) (was \(isMultilineInput)) → radius will be \(multiline ? 16 : 21)")
#endif
withAnimation(.easeInOut(duration: 0.2)) {
isMultilineInput = multiline
}
},
onTextHeightChange: { h in
withAnimation(.easeInOut(duration: 0.2)) {
textInputHeight = h
}
},
textColor: UIColor(RosettaColors.Adaptive.text),
placeholderColor: UIColor(RosettaColors.Adaptive.textSecondary.opacity(0.5))
)
.padding(.leading, 6)
.frame(maxWidth: .infinity, alignment: .bottomLeading)
.frame(height: textInputHeight)
HStack(alignment: .center, spacing: 0) {
Button {
switchToEmojiKeyboard()
} label: {
TelegramVectorIcon(
pathData: TelegramIconPath.emojiMoon,
viewBox: CGSize(width: 19, height: 19),
color: RosettaColors.Adaptive.textSecondary
)
.frame(width: 19, height: 19)
.frame(width: 20, height: 36)
}
.accessibilityLabel("Emoji")
.buttonStyle(.plain)
}
.padding(.trailing, 8 + (sendButtonWidth * sendButtonProgress))
.frame(height: 36, alignment: .center)
.overlay(alignment: .trailing) {
Button(action: sendCurrentMessage) {
TelegramVectorIcon(
pathData: TelegramIconPath.sendPlane,
viewBox: CGSize(width: 22, height: 19),
color: .white
)
.opacity(0.42 + (0.58 * sendButtonProgress))
.scaleEffect(0.72 + (0.28 * sendButtonProgress))
.frame(width: 22, height: 19)
.frame(width: sendButtonWidth, height: sendButtonHeight)
.background { Capsule().fill(Color(hex: 0x008BFF)) }
}
.accessibilityLabel("Send")
.disabled(!canSend)
.buttonStyle(ChatDetailGlassPressButtonStyle())
.allowsHitTesting(shouldShowSendButton)
.opacity(Double(sendButtonProgress))
.scaleEffect(0.74 + (0.26 * sendButtonProgress), anchor: .trailing)
.blur(radius: (1 - sendButtonProgress) * 2.1)
.mask(
Capsule()
.frame(
width: max(0.001, sendButtonWidth * sendButtonProgress),
height: max(0.001, sendButtonHeight * sendButtonProgress)
)
.frame(width: sendButtonWidth, height: sendButtonHeight, alignment: .trailing)
)
}
}
}
.padding(3)
.frame(minHeight: 42, alignment: .bottom)
.background { glass(shape: .rounded(isMultilineInput ? 16 : 21), strokeOpacity: 0.18) }
.clipShape(RoundedRectangle(cornerRadius: isMultilineInput ? 16 : 21, style: .continuous))
.padding(.leading, 6)
Button(action: trailingAction) {
TelegramVectorIcon(
pathData: TelegramIconPath.microphone,
viewBox: CGSize(width: 18, height: 24),
color: Color.white
)
.frame(width: 18, height: 24)
.frame(width: 42, height: 42)
.background { glass(shape: .circle, strokeOpacity: 0.18) }
}
.accessibilityLabel("Voice message")
.buttonStyle(ChatDetailGlassPressButtonStyle())
.allowsHitTesting(!shouldShowSendButton)
.opacity(Double(micButtonProgress))
.scaleEffect(
x: max(0.001, 0.42 + (0.58 * micButtonProgress)),
y: 0.78 + (0.22 * micButtonProgress),
anchor: .trailing
)
.blur(radius: (1 - micButtonProgress) * 2.4)
.padding(.leading, 6 * micButtonProgress)
.frame(width: (42 + 6) * micButtonProgress, height: 42, alignment: .trailing)
.clipped()
}
.padding(.leading, 16)
.padding(.trailing, composerTrailingPadding)
.padding(.top, 6)
.padding(.bottom, 12)
.simultaneousGesture(composerDismissGesture)
.animation(composerAnimation, value: canSend)
.animation(composerAnimation, value: shouldShowSendButton)
}
}
// MARK: - Message Index Lookup
/// PERF: O(1) index lookup via cached dictionary. Rebuilt lazily when messages change.
/// Avoids O(n) `firstIndex(where:)` per cell in reversed ForEach.
@MainActor private static var messageIndexCache: [String: Int] = [:]
@MainActor private static var messageIndexCacheKey: String = ""
private func messageIndex(for messageId: String) -> Int {
// Rebuild cache if messages array changed (first+last+count fingerprint).
let cacheKey = "\(messages.count)_\(messages.first?.id ?? "")_\(messages.last?.id ?? "")"
if Self.messageIndexCacheKey != cacheKey {
Self.messageIndexCache.removeAll(keepingCapacity: true)
for (i, msg) in messages.enumerated() {
Self.messageIndexCache[msg.id] = i
}
Self.messageIndexCacheKey = cacheKey
}
return Self.messageIndexCache[messageId] ?? 0
}
// MARK: - Bubble Position (Figma: Single / Top / Mid / Bottom)
/// Determines bubble position within a group of consecutive same-sender messages.
/// Telegram parity: photo messages group with text messages from the same sender.
func bubblePosition(for index: Int) -> BubblePosition {
let hasPrev: Bool = {
guard index > 0 else { return false }
let prev = messages[index - 1]
let current = messages[index]
let sameSender = current.isFromMe(myPublicKey: currentPublicKey)
== prev.isFromMe(myPublicKey: currentPublicKey)
return sameSender
}()
let hasNext: Bool = {
guard index + 1 < messages.count else { return false }
let next = messages[index + 1]
let current = messages[index]
let sameSender = current.isFromMe(myPublicKey: currentPublicKey)
== next.isFromMe(myPublicKey: currentPublicKey)
return sameSender
}()
switch (hasPrev, hasNext) {
case (false, false): return .single
case (false, true): return .top
case (true, true): return .mid
case (true, false): return .bottom
}
}
// MARK: - Glass
enum ChatGlassShape {
case capsule
case circle
case rounded(CGFloat)
}
@ViewBuilder
func glass(
shape: ChatGlassShape,
strokeOpacity: Double = 0.18,
strokeColor: Color = .white
) -> some View {
// Use TelegramGlass* UIViewRepresentable for ALL iOS versions.
// TelegramGlassUIView already supports iOS 26 natively (UIGlassEffect)
// and has isUserInteractionEnabled = false, guaranteeing touches pass
// through to parent Buttons. SwiftUI .glassEffect() modifier creates
// UIKit containers that intercept taps even with .allowsHitTesting(false).
switch shape {
case .capsule:
TelegramGlassCapsule()
case .circle:
TelegramGlassCircle()
case let .rounded(radius):
TelegramGlassRoundedRect(cornerRadius: radius)
}
}
// MARK: - Actions / utils
/// Opens the opponent profile sheet.
/// For Saved Messages and system accounts no profile to show.
func openProfile() {
guard !route.isSavedMessages, !route.isSystemAccount else { return }
isInputFocused = false
// Force-dismiss keyboard at UIKit level immediately.
// On iOS 26+, the async resignFirstResponder via syncFocus races with
// the navigation transition the system may re-focus the text view
// when returning from the profile, causing a ghost keyboard.
UIApplication.shared.sendAction(
#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil
)
showOpponentProfile = true
}
func trailingAction() {
if canSend { sendCurrentMessage() }
else { isInputFocused = true }
}
/// Opens the keyboard emoji button acts as a focus trigger.
/// System emoji keyboard is accessible via the 🌐 key on the keyboard.
func switchToEmojiKeyboard() {
if !isInputFocused { isInputFocused = true }
}
var composerDismissGesture: some Gesture {
DragGesture(minimumDistance: 10)
.onChanged { value in
guard isInputFocused else { return }
let vertical = value.translation.height
let horizontal = value.translation.width
guard vertical > 12, abs(vertical) > abs(horizontal) else { return }
isInputFocused = false
}
}
/// Lightweight sender name resolution for image viewer delegates to MessageCellView cache.
private func senderDisplayName(for publicKey: String) -> String {
if publicKey == currentPublicKey { return "You" }
if publicKey == route.publicKey && !route.title.isEmpty { return route.title }
if let dialog = DialogRepository.shared.dialogs[publicKey],
!dialog.opponentTitle.isEmpty { return dialog.opponentTitle }
if publicKey == route.publicKey && !route.username.isEmpty { return "@\(route.username)" }
return String(publicKey.prefix(8)) + ""
}
/// Lightweight reply blob parser for image viewer delegates to MessageCellView cache.
private func parseReplyBlob(_ blob: String) -> [ReplyMessageData]? {
guard !blob.isEmpty, let data = blob.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode([ReplyMessageData].self, from: data)
}
/// Collects all image attachments from the current chat and opens the gallery.
/// Android parity: `extractImagesFromMessages` in `ImageViewerScreen.kt` includes
/// 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) {
var allImages: [ViewableImageInfo] = []
for message in messages {
let senderName = senderDisplayName(for: message.fromPublicKey)
let timestamp = Date(timeIntervalSince1970: Double(message.timestamp) / 1000)
// Regular image attachments on the message itself.
for attachment in message.attachments where attachment.type == .image {
allImages.append(ViewableImageInfo(
attachmentId: attachment.id,
senderName: senderName,
timestamp: timestamp,
caption: message.text
))
}
// Forwarded image attachments inside reply/forward blobs.
for attachment in message.attachments where attachment.type == .messages {
if let replyMessages = parseReplyBlob(attachment.blob) {
for reply in replyMessages {
let fwdSenderName = senderDisplayName(for: reply.publicKey)
let fwdTimestamp = Date(timeIntervalSince1970: Double(reply.timestamp) / 1000)
for att in reply.attachments where att.type == AttachmentType.image.rawValue {
allImages.append(ViewableImageInfo(
attachmentId: att.id,
senderName: fwdSenderName,
timestamp: fwdTimestamp,
caption: reply.message
))
}
}
}
}
}
let index = allImages.firstIndex(where: { $0.attachmentId == attachmentId }) ?? 0
let state = ImageViewerState(images: allImages, initialIndex: index)
ImageViewerPresenter.shared.present(state: state)
}
func retryMessage(_ message: ChatMessage) {
let text = message.text
let toKey = message.toPublicKey
MessageRepository.shared.deleteMessage(id: message.id)
DialogRepository.shared.reconcileAfterMessageDelete(opponentKey: toKey)
Task {
try? await SessionManager.shared.sendMessage(text: text, toPublicKey: toKey)
}
}
func removeMessage(_ message: ChatMessage) {
MessageRepository.shared.deleteMessage(id: message.id)
DialogRepository.shared.reconcileAfterMessageDelete(opponentKey: message.toPublicKey)
}
// MARK: - Reply Bar
@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 }
// Parse filename from preview (tag::fileSize::fileName)
let parts = file.preview.components(separatedBy: "::")
if parts.count >= 3 { return parts[2] }
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" }
// 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
HStack(spacing: 0) {
RoundedRectangle(cornerRadius: 1.5)
.fill(RosettaColors.figmaBlue)
.frame(width: 3, height: 36)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 0) {
Text("Reply to ")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
Text(senderName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(RosettaColors.figmaBlue)
}
.lineLimit(1)
Text(previewText)
.font(.system(size: 14, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
}
.padding(.leading, 8)
Spacer()
Button {
withAnimation(.easeOut(duration: 0.15)) {
replyingToMessage = nil
}
} label: {
Image(systemName: "xmark")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.frame(width: 30, height: 30)
}
}
.padding(.leading, 6)
.padding(.trailing, 4)
.padding(.top, 6)
.padding(.bottom, 4)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
// MARK: - Forward
func forwardMessage(_ message: ChatMessage, to targetRoute: ChatRoute) {
#if DEBUG
print("═══════════════════════════════════════════════")
print("📤 FORWARD START")
print("📤 Original message: id=\(message.id.prefix(16)), text='\(message.text.prefix(30))'")
print("📤 Original attachments (\(message.attachments.count)):")
for att in message.attachments {
print("📤 - type=\(att.type) id=\(att.id.prefix(16)) preview='\(att.preview.prefix(40))' blob=\(att.blob.isEmpty ? "(empty)" : "(\(att.blob.count) chars, starts: \(att.blob.prefix(30)))")")
}
print("📤 Attachment password: \(message.attachmentPassword?.prefix(20) ?? "nil")")
print("📤 Target: \(targetRoute.publicKey.prefix(16))")
#endif
// Android parity: unwrap nested forwards.
// If the message being forwarded is itself a forward, extract the inner
// forwarded messages and re-forward them directly (flatten).
let forwardDataList: [ReplyMessageData]
let replyAttachment = message.attachments.first(where: { $0.type == .messages })
let isForward = message.text.trimmingCharacters(in: .whitespaces).isEmpty
#if DEBUG
if let att = replyAttachment {
let blobParsed = parseReplyBlob(att.blob)
let previewParsed = parseReplyBlob(att.preview)
print("📤 Unwrap check: isForward=\(isForward)")
print("📤 blob parse: \(blobParsed == nil ? "FAILED" : "OK (\(blobParsed!.count) msgs, atts: \(blobParsed!.map { $0.attachments.count }))")")
print("📤 preview parse: \(previewParsed == nil ? "FAILED (preview='\(att.preview.prefix(20))')" : "OK (\(previewParsed!.count) msgs)")")
}
#endif
if isForward,
let att = replyAttachment,
let innerMessages = (parseReplyBlob(att.blob) ?? parseReplyBlob(att.preview)),
!innerMessages.isEmpty {
// Unwrap: forward the original messages, not the wrapper
forwardDataList = innerMessages
#if DEBUG
print("📤 ✅ UNWRAP path: \(innerMessages.count) inner message(s)")
for (i, msg) in innerMessages.enumerated() {
print("📤 msg[\(i)]: publicKey=\(msg.publicKey.prefix(12)), text='\(msg.message.prefix(30))', attachments=\(msg.attachments.count)")
for att in msg.attachments {
print("📤 att: type=\(att.type) id=\(att.id.prefix(16)) preview='\(att.preview.prefix(40))'")
}
}
#endif
} else {
// Regular message forward as-is
forwardDataList = [buildReplyData(from: message)]
#if DEBUG
print("📤 ⚠️ BUILD_REPLY_DATA path (unwrap failed or not a forward)")
if let first = forwardDataList.first {
print("📤 result: publicKey=\(first.publicKey.prefix(12)), text='\(first.message.prefix(30))', attachments=\(first.attachments.count)")
for att in first.attachments {
print("📤 att: type=\(att.type) id=\(att.id.prefix(16)) preview='\(att.preview.prefix(40))'")
}
}
#endif
}
// Collect attachment password for CDN downloads of uncached images.
let storedPassword = message.attachmentPassword
let targetKey = targetRoute.publicKey
let targetTitle = targetRoute.title
let targetUsername = targetRoute.username
Task { @MainActor in
// Android parity: collect cached images for re-upload.
// Android re-encrypts + re-uploads each photo with the new message key.
// Without this, Desktop tries to decrypt CDN blob with the wrong key.
var forwardedImages: [String: Data] = [:]
var forwardedFiles: [String: (data: Data, fileName: String)] = [:]
for replyData in forwardDataList {
for att in replyData.attachments {
if att.type == AttachmentType.image.rawValue {
// Image re-upload
if let image = AttachmentCache.shared.loadImage(forAttachmentId: att.id),
let jpegData = image.jpegData(compressionQuality: 0.85) {
forwardedImages[att.id] = jpegData
#if DEBUG
print("📤 Image \(att.id.prefix(16)): loaded from cache (\(jpegData.count) bytes)")
#endif
continue
}
// Not in cache download from CDN, decrypt, then include.
let cdnTag = att.preview.components(separatedBy: "::").first ?? ""
guard !cdnTag.isEmpty else {
#if DEBUG
print("📤 Image \(att.id.prefix(16)): SKIP — empty CDN tag, preview='\(att.preview.prefix(30))'")
#endif
continue
}
let password = storedPassword ?? ""
guard !password.isEmpty else {
#if DEBUG
print("📤 Image \(att.id.prefix(16)): SKIP — no attachment password")
#endif
continue
}
do {
#if DEBUG
print("📤 Image \(att.id.prefix(16)): downloading from CDN tag=\(cdnTag.prefix(16))...")
#endif
let encryptedData = try await TransportManager.shared.downloadFile(tag: cdnTag)
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
let passwords = MessageCrypto.attachmentPasswordCandidates(from: password)
if let img = Self.decryptForwardImage(encryptedString: encryptedString, passwords: passwords),
let jpegData = img.jpegData(compressionQuality: 0.85) {
forwardedImages[att.id] = jpegData
AttachmentCache.shared.saveImage(img, forAttachmentId: att.id)
#if DEBUG
print("📤 Image \(att.id.prefix(16)): CDN download+decrypt OK (\(jpegData.count) bytes)")
#endif
} else {
#if DEBUG
print("📤 Image \(att.id.prefix(16)): CDN download OK but DECRYPT FAILED (\(encryptedData.count) bytes, \(passwords.count) candidates)")
#endif
}
} catch {
#if DEBUG
print("📤 Image \(att.id.prefix(16)): CDN download FAILED: \(error)")
#endif
}
} else if att.type == AttachmentType.file.rawValue {
// File re-upload (Desktop parity: prepareAttachmentsToSend)
let parts = att.preview.components(separatedBy: "::")
let fileName = parts.count > 2 ? parts[2] : "file"
// Try local cache first
if let fileData = AttachmentCache.shared.loadFileData(forAttachmentId: att.id, fileName: fileName) {
forwardedFiles[att.id] = (data: fileData, fileName: fileName)
#if DEBUG
print("📤 File \(att.id.prefix(16)): loaded from cache (\(fileData.count) bytes, name=\(fileName))")
#endif
continue
}
// Not in cache download from CDN, decrypt
let cdnTag = parts.first ?? ""
guard !cdnTag.isEmpty else {
#if DEBUG
print("📤 File \(att.id.prefix(16)): SKIP — empty CDN tag")
#endif
continue
}
let password = storedPassword ?? ""
guard !password.isEmpty else {
#if DEBUG
print("📤 File \(att.id.prefix(16)): SKIP — no attachment password")
#endif
continue
}
do {
#if DEBUG
print("📤 File \(att.id.prefix(16)): downloading from CDN tag=\(cdnTag.prefix(16))...")
#endif
let encryptedData = try await TransportManager.shared.downloadFile(tag: cdnTag)
let encryptedString = String(decoding: encryptedData, as: UTF8.self)
let passwords = MessageCrypto.attachmentPasswordCandidates(from: password)
if let fileData = Self.decryptForwardFile(encryptedString: encryptedString, passwords: passwords) {
forwardedFiles[att.id] = (data: fileData, fileName: fileName)
#if DEBUG
print("📤 File \(att.id.prefix(16)): CDN download+decrypt OK (\(fileData.count) bytes, name=\(fileName))")
#endif
} else {
#if DEBUG
print("📤 File \(att.id.prefix(16)): CDN download OK but DECRYPT FAILED (\(encryptedData.count) bytes)")
#endif
}
} catch {
#if DEBUG
print("📤 File \(att.id.prefix(16)): CDN download FAILED: \(error)")
#endif
}
} else {
#if DEBUG
print("📤 Attachment \(att.id.prefix(16)): SKIP — type=\(att.type)")
#endif
}
}
}
#if DEBUG
print("📤 ── SEND SUMMARY ──")
print("📤 forwardDataList: \(forwardDataList.count) message(s)")
for (i, msg) in forwardDataList.enumerated() {
print("📤 msg[\(i)]: text='\(msg.message.prefix(20))' attachments=\(msg.attachments.count) (images: \(msg.attachments.filter { $0.type == 0 }.count), files: \(msg.attachments.filter { $0.type == 2 }.count))")
}
print("📤 forwardedImages: \(forwardedImages.count) re-uploads")
print("📤 forwardedFiles: \(forwardedFiles.count) re-uploads")
#endif
do {
try await SessionManager.shared.sendMessageWithReply(
text: "",
replyMessages: forwardDataList,
toPublicKey: targetKey,
opponentTitle: targetTitle,
opponentUsername: targetUsername,
forwardedImages: forwardedImages,
forwardedFiles: forwardedFiles
)
#if DEBUG
print("📤 ✅ FORWARD SENT OK")
print("═══════════════════════════════════════════════")
#endif
} catch {
#if DEBUG
print("📤 ❌ FORWARD FAILED: \(error)")
print("═══════════════════════════════════════════════")
#endif
sendError = "Failed to forward message"
}
}
}
/// Decrypt a CDN-downloaded image blob with multiple password candidates.
private static func decryptForwardImage(encryptedString: String, passwords: [String]) -> UIImage? {
let crypto = CryptoManager.shared
for password in passwords {
if let data = try? crypto.decryptWithPassword(encryptedString, password: password, requireCompression: true),
let img = parseForwardImageData(data) { return img }
}
for password in passwords {
if let data = try? crypto.decryptWithPassword(encryptedString, password: password),
let img = parseForwardImageData(data) { return img }
}
return nil
}
private static func parseForwardImageData(_ data: Data) -> UIImage? {
if let str = String(data: data, encoding: .utf8),
str.hasPrefix("data:"),
let commaIndex = str.firstIndex(of: ",") {
let base64Part = String(str[str.index(after: commaIndex)...])
if let imageData = Data(base64Encoded: base64Part) {
return UIImage(data: imageData)
}
}
return UIImage(data: data)
}
/// Decrypt a CDN-downloaded file blob with multiple password candidates.
/// Returns raw file data (extracted from data URI).
private static func decryptForwardFile(encryptedString: String, passwords: [String]) -> Data? {
let crypto = CryptoManager.shared
for password in passwords {
if let data = try? crypto.decryptWithPassword(encryptedString, password: password, requireCompression: true),
let fileData = parseForwardFileData(data) { return fileData }
}
for password in passwords {
if let data = try? crypto.decryptWithPassword(encryptedString, password: password),
let fileData = parseForwardFileData(data) { return fileData }
}
return nil
}
/// Extract raw file bytes from a data URI (format: "data:{mime};base64,{base64data}").
private static func parseForwardFileData(_ data: Data) -> Data? {
if let str = String(data: data, encoding: .utf8),
str.hasPrefix("data:"),
let commaIndex = str.firstIndex(of: ",") {
let base64Part = String(str[str.index(after: commaIndex)...])
return Data(base64Encoded: base64Part)
}
// Not a data URI return raw data
return data
}
/// Builds a `ReplyMessageData` from a `ChatMessage` for reply/forward encoding.
/// Desktop parity: `MessageReply` in `useReplyMessages.ts`.
private func buildReplyData(from message: ChatMessage) -> ReplyMessageData {
// Convert ChatMessage attachments to ReplyAttachmentData (text-only for now)
let replyAttachments: [ReplyAttachmentData] = message.attachments.compactMap { att in
// Skip MESSAGES attachments in nested replies (don't nest replies recursively)
guard att.type != .messages else { return nil }
return ReplyAttachmentData(
id: att.id,
type: att.type.rawValue,
preview: att.preview,
blob: "" // Blob cleared for reply (desktop parity)
)
}
// If no non-messages attachments but has a .messages attachment (forward),
// try to extract the inner message's data to preserve photos/files.
// This handles the case where forwardMessage() unwrap failed but the blob is actually parseable.
if replyAttachments.isEmpty,
let msgAtt = message.attachments.first(where: { $0.type == .messages }),
let innerMessages = parseReplyBlob(msgAtt.blob),
let firstInner = innerMessages.first {
#if DEBUG
print("📤 buildReplyData: extracted inner message with \(firstInner.attachments.count) attachments")
#endif
return firstInner
}
// Filter garbage text (U+FFFD from failed decryption) don't send ciphertext/garbage to recipient.
let cleanText = MessageCellView.isGarbageText(message.text) ? "" : message.text
return ReplyMessageData(
message_id: message.id,
publicKey: message.fromPublicKey,
message: cleanText,
timestamp: message.timestamp,
attachments: replyAttachments
)
}
func scrollToBottom(proxy: ScrollViewProxy, animated: Bool) {
// Inverted scroll: .top anchor in scroll coordinates = visual bottom on screen.
if animated {
withAnimation(.easeOut(duration: 0.2)) {
proxy.scrollTo(Self.scrollBottomAnchorId, anchor: .top)
}
} else {
proxy.scrollTo(Self.scrollBottomAnchorId, anchor: .top)
}
}
// isTailVisible replaced by bubblePosition(for:) above
func requestUserInfoIfNeeded() {
// Always request we need fresh online status even if title is already populated.
SessionManager.shared.requestUserInfoIfNeeded(forKey: route.publicKey)
}
func activateDialog() {
// Only update existing dialogs; don't create ghost entries from search.
// New dialogs are created when messages are sent/received (SessionManager).
if DialogRepository.shared.dialogs[route.publicKey] != nil {
DialogRepository.shared.ensureDialog(
opponentKey: route.publicKey,
title: route.title,
username: route.username,
verified: route.verified,
myPublicKey: currentPublicKey
)
}
MessageRepository.shared.setDialogActive(route.publicKey, isActive: true)
}
func markDialogAsRead() {
DialogRepository.shared.markAsRead(opponentKey: route.publicKey)
MessageRepository.shared.markIncomingAsRead(opponentKey: route.publicKey, myPublicKey: currentPublicKey)
// Desktop parity: don't send read receipts for system accounts
if !route.isSystemAccount {
SessionManager.shared.sendReadReceipt(toPublicKey: route.publicKey)
}
}
/// Remove all delivered push notifications from this specific sender.
func clearDeliveredNotifications(for senderKey: String) {
let center = UNUserNotificationCenter.current()
center.getDeliveredNotifications { delivered in
let idsToRemove = delivered
.filter { $0.request.content.userInfo["sender_public_key"] as? String == senderKey }
.map { $0.request.identifier }
if !idsToRemove.isEmpty {
center.removeDeliveredNotifications(withIdentifiers: idsToRemove)
}
}
}
func sendCurrentMessage() {
let message = trimmedMessage
let attachments = pendingAttachments
let replyMessage = replyingToMessage
// Must have either text or attachments
guard !message.isEmpty || !attachments.isEmpty else { return }
shouldScrollOnNextMessage = true
messageText = ""
pendingAttachments = []
replyingToMessage = nil
sendError = nil
// Desktop parity: delete draft after sending.
DraftManager.shared.deleteDraft(for: route.publicKey)
Task { @MainActor in
do {
if !attachments.isEmpty {
// Send message with attachments
try await SessionManager.shared.sendMessageWithAttachments(
text: message,
attachments: attachments,
toPublicKey: route.publicKey,
opponentTitle: route.title,
opponentUsername: route.username
)
} else if let replyMsg = replyMessage {
// Desktop parity: reply sends MESSAGES attachment with quoted message JSON
let replyData = buildReplyData(from: replyMsg)
try await SessionManager.shared.sendMessageWithReply(
text: message,
replyMessages: [replyData],
toPublicKey: route.publicKey,
opponentTitle: route.title,
opponentUsername: route.username
)
} else {
// Text-only message (existing path)
try await SessionManager.shared.sendMessage(
text: message,
toPublicKey: route.publicKey,
opponentTitle: route.title,
opponentUsername: route.username
)
}
} catch {
sendError = "Failed to send message"
if messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
messageText = message
}
}
}
}
/// Handles attachments selected from the attachment panel.
/// Always sends immediately no preview step.
func handleAttachmentsSend(_ attachments: [PendingAttachment]) {
let remaining = PendingAttachment.maxAttachmentsPerMessage - pendingAttachments.count
let toAdd = Array(attachments.prefix(remaining))
pendingAttachments.append(contentsOf: toAdd)
sendCurrentMessage()
}
/// Desktop parity: onClickCamera() sends current user's avatar to this chat.
func sendAvatarToChat() {
guard !isSendingAvatar else { return }
isSendingAvatar = true
sendError = nil
Task { @MainActor in
do {
try await SessionManager.shared.sendAvatar(
toPublicKey: route.publicKey,
opponentTitle: route.title,
opponentUsername: route.username
)
} catch {
sendError = "Failed to send avatar"
}
isSendingAvatar = false
}
}
func handleComposerUserTyping() {
SessionManager.shared.sendTypingIndicator(toPublicKey: route.publicKey)
}
}
// MARK: - Nav Bar Style (ChatDetail-specific)
/// ChatDetail uses a transparent nav bar so glass/blur pills float independently.
/// ChatListView & SettingsView keep `.applyGlassNavBar()` with `.regularMaterial`.
private struct ChatDetailNavBarStyleModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26, *) {
content
.toolbarBackground(.hidden, for: .navigationBar)
} else {
content
.toolbarBackground(.hidden, for: .navigationBar)
}
}
}
// MARK: - Button Styles
private struct ChatDetailGlassPressButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 1.04 : 1.0)
.brightness(configuration.isPressed ? 0.06 : 0)
.overlay {
if configuration.isPressed {
Capsule(style: .continuous)
.fill(Color.white.opacity(0.16))
.padding(1)
.allowsHitTesting(false)
}
}
.animation(.spring(response: 0.22, dampingFraction: 0.72), value: configuration.isPressed)
}
}
private struct ChatDetailGlassCirclePressStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
// почти незаметное сжатие
.scaleEffect(configuration.isPressed ? 0.988 : 1.0)
// очень лёгкое подсвечивание
.brightness(configuration.isPressed ? 0.025 : 0.0)
.overlay {
if configuration.isPressed {
Circle()
.fill(Color.white.opacity(0.10))
.blendMode(.overlay)
.padding(2)
// тонкий inner highlight
Circle()
.stroke(Color.white.opacity(0.18), lineWidth: 0.9)
.padding(2)
.blendMode(.overlay)
}
}
.animation(.spring(response: 0.20, dampingFraction: 0.85), value: configuration.isPressed)
}
}
// MARK: - SVG
struct TelegramVectorIcon: View {
let pathData: String
let viewBox: CGSize
let color: Color
var body: some View {
SVGPathShape(pathData: pathData, viewBox: viewBox)
.fill(color)
}
}
enum TelegramIconPath {
static let backChevron = #"M0.317383 10.5957C0.203451 10.498 0.12207 10.376 0.0732422 10.2295C0.0244141 10.0993 0 9.96094 0 9.81445C0 9.66797 0.0244141 9.52962 0.0732422 9.39941C0.12207 9.25293 0.203451 9.13086 0.317383 9.0332L8.83789 0.317383C8.93555 0.219727 9.05762 0.138346 9.2041 0.0732422C9.33431 0.0244141 9.47266 0 9.61914 0C9.74935 0 9.87956 0.0244141 10.0098 0.0732422C10.1562 0.138346 10.2783 0.219727 10.376 0.317383C10.4899 0.431315 10.5713 0.553385 10.6201 0.683594C10.6689 0.830078 10.6934 0.976562 10.6934 1.12305C10.6934 1.25326 10.6689 1.3916 10.6201 1.53809C10.5713 1.66829 10.4899 1.79036 10.376 1.9043L2.63672 9.81445L10.376 17.7246C10.4899 17.8385 10.5713 17.9606 10.6201 18.0908C10.6689 18.2373 10.6934 18.3757 10.6934 18.5059C10.6934 18.6523 10.6689 18.7988 10.6201 18.9453C10.5713 19.0755 10.4899 19.1976 10.376 19.3115C10.2783 19.4092 10.1562 19.4906 10.0098 19.5557C9.87956 19.6045 9.74935 19.6289 9.61914 19.6289C9.47266 19.6289 9.33431 19.6045 9.2041 19.5557C9.05762 19.4906 8.93555 19.4092 8.83789 19.3115L0.317383 10.5957Z"#
static let paperclip = #"M11.0156 17.9297L9.84375 16.7871L17.4316 9.11133C17.8418 8.70117 18.1543 8.22266 18.3691 7.67578C18.584 7.14844 18.6914 6.5918 18.6914 6.00586C18.6914 5.43945 18.584 4.88281 18.3691 4.33594C18.1348 3.80859 17.8125 3.33984 17.4023 2.92969C16.9922 2.51953 16.5137 2.20703 15.9668 1.99219C15.4395 1.75781 14.8828 1.65039 14.2969 1.66992C13.7109 1.66992 13.1543 1.77734 12.627 1.99219C12.0801 2.22656 11.6016 2.54883 11.1914 2.95898L3.60352 10.6055C2.97852 11.2305 2.5 11.9531 2.16797 12.7734C1.83594 13.5742 1.66992 14.4141 1.66992 15.293C1.66992 16.1719 1.8457 17.0215 2.19727 17.8418C2.5293 18.6426 3.00781 19.3555 3.63281 19.9805C4.25781 20.6055 4.98047 21.084 5.80078 21.416C6.62109 21.748 7.4707 21.9141 8.34961 21.9141C9.22852 21.8945 10.0684 21.7188 10.8691 21.3867C11.6895 21.0547 12.4121 20.5762 13.0371 19.9512L18.5449 14.3848C18.7012 14.2285 18.8965 14.1504 19.1309 14.1504C19.3652 14.1504 19.5605 14.2285 19.7168 14.3848C19.873 14.541 19.9512 14.7363 19.9512 14.9707C19.9707 15.2051 19.8926 15.4004 19.7168 15.5566L14.1211 21.1816C13.3594 21.9434 12.4805 22.5293 11.4844 22.9395C10.4688 23.3496 9.42383 23.5547 8.34961 23.5547C8.33008 23.5547 8.04688 23.5547 7.5 23.5547C6.95312 23.5547 6.17188 23.3496 5.15625 22.9395C4.14062 22.5293 3.24219 21.9336 2.46094 21.1523C1.67969 20.3906 1.07422 19.502 0.644531 18.4863C0.234375 17.4707 0.0195312 16.4062 0 15.293V15.2637C0 14.1699 0.214844 13.125 0.644531 12.1289C1.05469 11.1133 1.64062 10.2148 2.40234 9.43359L10.0195 1.78711C10.5859 1.2207 11.2402 0.78125 11.9824 0.46875C12.7246 0.15625 13.4961 0 14.2969 0H14.3262C15.1074 0 15.8691 0.146484 16.6113 0.439453C17.3535 0.751953 18.0078 1.18164 18.5742 1.72852C19.1406 2.29492 19.5801 2.94922 19.8926 3.69141C20.2051 4.43359 20.3613 5.20508 20.3613 6.00586V6.03516C20.3613 6.83594 20.2148 7.59766 19.9219 8.32031C19.6094 9.0625 19.1699 9.7168 18.6035 10.2832L11.0156 17.9297ZM10.957 6.88477C11.0352 6.80664 11.1328 6.74805 11.25 6.70898C11.3477 6.66992 11.4453 6.65039 11.543 6.65039C11.6602 6.65039 11.7676 6.66992 11.8652 6.70898C11.9629 6.74805 12.0508 6.80664 12.1289 6.88477C12.207 6.96289 12.2754 7.05078 12.334 7.14844C12.373 7.24609 12.3926 7.35352 12.3926 7.4707C12.3926 7.56836 12.373 7.67578 12.334 7.79297C12.2754 7.89063 12.207 7.97852 12.1289 8.05664L6.62109 13.623C6.40625 13.8184 6.25 14.0527 6.15234 14.3262C6.03516 14.6191 5.97656 14.9121 5.97656 15.2051C5.97656 15.498 6.03516 15.7812 6.15234 16.0547C6.26953 16.3281 6.43555 16.5723 6.65039 16.7871C6.86523 17.002 7.10938 17.168 7.38281 17.2852C7.65625 17.3828 7.93945 17.4316 8.23242 17.4316C8.54492 17.4316 8.83789 17.373 9.11133 17.2559C9.38477 17.1387 9.62891 16.9824 9.84375 16.7871L11.0156 17.9297C10.6445 18.3008 10.2246 18.584 9.75586 18.7793C9.26758 18.9941 8.75977 19.1016 8.23242 19.1016C7.70508 19.1016 7.20703 19.0039 6.73828 18.8086C6.26953 18.6133 5.84961 18.3301 5.47852 17.959C5.10742 17.5879 4.82422 17.168 4.62891 16.6992C4.41406 16.2305 4.30664 15.7324 4.30664 15.2051V15.1758C4.30664 14.6875 4.4043 14.209 4.59961 13.7402C4.77539 13.291 5.0293 12.8809 5.36133 12.5098L10.957 6.88477Z"#
static let emojiMoon = #"M5.79492 9.92773C5.8099 9.92773 5.82487 9.92773 5.83984 9.92773C5.85482 9.91276 5.86979 9.90527 5.88477 9.90527C5.95964 9.90527 6.03451 9.92025 6.10938 9.9502C6.16927 9.96517 6.22917 9.99512 6.28906 10.04C6.36393 10.0999 6.43132 10.1673 6.49121 10.2422C6.55111 10.3171 6.59603 10.3994 6.62598 10.4893C6.6709 10.5941 6.71582 10.6914 6.76074 10.7812C6.82064 10.8711 6.88053 10.9535 6.94043 11.0283C7.00033 11.1182 7.06771 11.2005 7.14258 11.2754C7.21745 11.3652 7.29232 11.4401 7.36719 11.5C7.50195 11.6198 7.63672 11.7171 7.77148 11.792C7.90625 11.8818 8.0485 11.9492 8.19824 11.9941C8.36296 12.054 8.52767 12.099 8.69238 12.1289C8.84212 12.1589 8.99935 12.1738 9.16406 12.1738C9.32878 12.1738 9.49349 12.1589 9.6582 12.1289C9.82292 12.099 9.98014 12.054 10.1299 11.9941C10.2796 11.9492 10.4219 11.8893 10.5566 11.8145C10.7064 11.7246 10.8486 11.6273 10.9834 11.5225C11.0583 11.4626 11.1331 11.3877 11.208 11.2979C11.2679 11.223 11.3278 11.1406 11.3877 11.0508C11.4626 10.9609 11.5225 10.8711 11.5674 10.7812C11.6273 10.6914 11.6797 10.5941 11.7246 10.4893C11.7546 10.3994 11.7995 10.3245 11.8594 10.2646C11.9043 10.1898 11.9642 10.1224 12.0391 10.0625C12.099 10.0176 12.1663 9.98763 12.2412 9.97266C12.3011 9.94271 12.3685 9.92773 12.4434 9.92773C12.4583 9.92773 12.4733 9.92773 12.4883 9.92773C12.5033 9.92773 12.5182 9.93522 12.5332 9.9502C12.638 9.9502 12.7204 9.97266 12.7803 10.0176C12.8551 10.0625 12.9225 10.1224 12.9824 10.1973C13.0273 10.2572 13.0573 10.3096 13.0723 10.3545C13.0872 10.4144 13.0947 10.4743 13.0947 10.5342C13.0947 10.5641 13.0947 10.5866 13.0947 10.6016C13.0798 10.6315 13.0723 10.6615 13.0723 10.6914C13.0124 10.8711 12.9375 11.0433 12.8477 11.208C12.7728 11.3877 12.6829 11.5524 12.5781 11.7021C12.4733 11.8669 12.361 12.0166 12.2412 12.1514C12.1214 12.3011 11.9867 12.4359 11.8369 12.5557C11.6423 12.7204 11.4401 12.8626 11.2305 12.9824C11.0208 13.1022 10.8037 13.207 10.5791 13.2969C10.3545 13.3867 10.1224 13.4541 9.88281 13.499C9.64323 13.5439 9.39616 13.5664 9.1416 13.5664C8.90202 13.5664 8.66243 13.5439 8.42285 13.499C8.18327 13.4541 7.94368 13.3867 7.7041 13.2969C7.47949 13.207 7.26237 13.1022 7.05273 12.9824C6.8431 12.8477 6.64095 12.7054 6.44629 12.5557C6.31152 12.4209 6.18424 12.2861 6.06445 12.1514C5.92969 12.0016 5.8099 11.8444 5.70508 11.6797C5.61523 11.5299 5.52539 11.3727 5.43555 11.208C5.36068 11.0433 5.29329 10.8711 5.2334 10.6914C5.21842 10.6465 5.21094 10.609 5.21094 10.5791C5.21094 10.5492 5.21094 10.5192 5.21094 10.4893C5.21094 10.4443 5.21842 10.3919 5.2334 10.332C5.24837 10.2871 5.27083 10.2422 5.30078 10.1973C5.42057 10.0326 5.58529 9.94271 5.79492 9.92773ZM6.44629 6.08691C6.59603 6.08691 6.73079 6.11686 6.85059 6.17676C6.98535 6.23665 7.09766 6.3265 7.1875 6.44629C7.29232 6.56608 7.37467 6.69336 7.43457 6.82812C7.47949 6.96289 7.50195 7.10514 7.50195 7.25488C7.50195 7.4196 7.47949 7.57682 7.43457 7.72656C7.37467 7.86133 7.29232 7.98861 7.1875 8.1084C7.09766 8.21322 6.98535 8.29557 6.85059 8.35547C6.73079 8.41536 6.59603 8.44531 6.44629 8.44531C6.29655 8.44531 6.16178 8.41536 6.04199 8.35547C5.9222 8.29557 5.80241 8.21322 5.68262 8.1084C5.59277 7.98861 5.5179 7.86133 5.45801 7.72656C5.41309 7.57682 5.39062 7.4196 5.39062 7.25488C5.39062 7.10514 5.41309 6.96289 5.45801 6.82812C5.5179 6.69336 5.59277 6.56608 5.68262 6.44629C5.80241 6.3265 5.9222 6.23665 6.04199 6.17676C6.16178 6.11686 6.29655 6.08691 6.44629 6.08691ZM11.6123 6.08691C11.7471 6.08691 11.8818 6.11686 12.0166 6.17676C12.1364 6.23665 12.2487 6.3265 12.3535 6.44629C12.4583 6.56608 12.5332 6.69336 12.5781 6.82812C12.638 6.96289 12.668 7.10514 12.668 7.25488C12.668 7.4196 12.638 7.57682 12.5781 7.72656C12.5332 7.86133 12.4583 7.98861 12.3535 8.1084C12.2487 8.21322 12.1364 8.29557 12.0166 8.35547C11.8818 8.41536 11.7471 8.44531 11.6123 8.44531C11.4626 8.44531 11.3203 8.41536 11.1855 8.35547C11.0658 8.29557 10.9535 8.21322 10.8486 8.1084C10.7438 7.98861 10.6689 7.86133 10.624 7.72656C10.5791 7.57682 10.5566 7.4196 10.5566 7.25488C10.5566 7.10514 10.5791 6.96289 10.624 6.82812C10.6689 6.69336 10.7438 6.56608 10.8486 6.44629C10.9535 6.3265 11.0658 6.23665 11.1855 6.17676C11.3203 6.11686 11.4626 6.08691 11.6123 6.08691ZM6.06445 1.99902C5.60026 2.19368 5.16602 2.43327 4.76172 2.71777C4.35742 2.9873 3.98307 3.29427 3.63867 3.63867C3.29427 3.98307 2.9873 4.35742 2.71777 4.76172C2.44824 5.16602 2.20866 5.60026 1.99902 6.06445C1.6097 6.97786 1.41504 7.96615 1.41504 9.0293C1.41504 9.55339 1.45996 10.0625 1.5498 10.5566C1.65462 11.0508 1.80436 11.5299 1.99902 11.9941C2.20866 12.4583 2.44824 12.8926 2.71777 13.2969C2.9873 13.7012 3.29427 14.0755 3.63867 14.4199C3.98307 14.7643 4.35742 15.0713 4.76172 15.3408C5.16602 15.6104 5.60026 15.8499 6.06445 16.0596C6.52865 16.2542 7.00781 16.404 7.50195 16.5088C7.99609 16.5986 8.50521 16.6436 9.0293 16.6436C9.55339 16.6436 10.0625 16.5986 10.5566 16.5088C11.0508 16.404 11.5299 16.2542 11.9941 16.0596C12.4583 15.8499 12.8926 15.6104 13.2969 15.3408C13.7012 15.0713 14.0755 14.7643 14.4199 14.4199C14.7643 14.0755 15.0713 13.7012 15.3408 13.2969C15.6104 12.8926 15.8499 12.4583 16.0596 11.9941C16.2542 11.5299 16.404 11.0508 16.5088 10.5566C16.5986 10.0625 16.6436 9.55339 16.6436 9.0293C16.6436 8.49023 16.5986 7.97363 16.5088 7.47949C16.404 6.98535 16.2542 6.51367 16.0596 6.06445C15.8499 5.60026 15.6104 5.16602 15.3408 4.76172C15.0713 4.35742 14.7643 3.98307 14.4199 3.63867C14.0755 3.29427 13.7012 2.9873 13.2969 2.71777C12.8926 2.43327 12.4583 2.19368 11.9941 1.99902C11.5299 1.80436 11.0508 1.65462 10.5566 1.5498C10.0625 1.44499 9.55339 1.39258 9.0293 1.39258C8.50521 1.39258 7.99609 1.44499 7.50195 1.5498C7.00781 1.65462 6.52865 1.80436 6.06445 1.99902ZM12.5332 0.696289C13.0872 0.935872 13.6038 1.21289 14.083 1.52734C14.5622 1.85677 15.0039 2.22363 15.4082 2.62793C15.8275 3.0472 16.1943 3.49642 16.5088 3.97559C16.8382 4.45475 17.1227 4.96387 17.3623 5.50293C17.6019 6.05697 17.7741 6.62598 17.8789 7.20996C17.9987 7.79395 18.0586 8.40039 18.0586 9.0293C18.0586 9.64323 17.9987 10.2497 17.8789 10.8486C17.7741 11.4326 17.6019 11.9941 17.3623 12.5332C17.1227 13.0872 16.8382 13.6038 16.5088 14.083C16.1943 14.5771 15.8275 15.0264 15.4082 15.4307C14.5996 16.2393 13.6413 16.8831 12.5332 17.3623C11.9941 17.5869 11.4326 17.7591 10.8486 17.8789C10.2646 17.9987 9.6582 18.0586 9.0293 18.0586C8.40039 18.0586 7.79395 17.9987 7.20996 17.8789C6.62598 17.7591 6.06445 17.5869 5.52539 17.3623C4.97135 17.1227 4.44727 16.8457 3.95312 16.5312C3.47396 16.2018 3.03223 15.835 2.62793 15.4307C1.81934 14.6071 1.17546 13.6413 0.696289 12.5332C0.456706 11.9941 0.284505 11.4326 0.179688 10.8486C0.0598958 10.2497 0 9.64323 0 9.0293C0 8.40039 0.0598958 7.79395 0.179688 7.20996C0.284505 6.62598 0.456706 6.05697 0.696289 5.50293C0.935872 4.96387 1.21289 4.45475 1.52734 3.97559C1.85677 3.49642 2.22363 3.0472 2.62793 2.62793C3.03223 2.22363 3.47396 1.85677 3.95312 1.52734C4.44727 1.21289 4.97135 0.935872 5.52539 0.696289C6.06445 0.456706 6.62598 0.277018 7.20996 0.157227C7.79395 0.0524089 8.40039 0 9.0293 0C9.6582 0 10.2646 0.0524089 10.8486 0.157227C11.4326 0.277018 11.9941 0.456706 12.5332 0.696289Z"#
static let sendPlane = #"M1.47656 7.84766C4.42969 6.57161 6.89062 5.50521 8.85938 4.64844C10.8281 3.8099 12.3047 3.18099 13.2891 2.76172C15.1849 1.97786 16.6159 1.39453 17.582 1.01172C18.5664 0.628906 19.3047 0.364583 19.7969 0.21875C20.2344 0.0729167 20.5807 0 20.8359 0H20.8633C20.9727 0 21.0911 0.0182292 21.2188 0.0546875C21.3828 0.0911458 21.5195 0.154948 21.6289 0.246094C21.7201 0.31901 21.793 0.410156 21.8477 0.519531C21.8659 0.592448 21.8932 0.683594 21.9297 0.792969C21.9297 0.865885 21.9388 0.947917 21.957 1.03906C21.957 1.14844 21.957 1.2487 21.957 1.33984C21.957 1.39453 21.957 1.4401 21.957 1.47656C21.957 1.51302 21.957 1.54948 21.957 1.58594C21.8112 3.02604 21.474 5.40495 20.9453 8.72266C20.4896 11.4753 20.0612 13.9544 19.6602 16.1602C19.5326 16.8529 19.332 17.3815 19.0586 17.7461C18.8398 18.0378 18.5755 18.2018 18.2656 18.2383C18.2292 18.2383 18.2018 18.2383 18.1836 18.2383C18.1654 18.2383 18.138 18.2383 18.1016 18.2383C17.8464 18.2383 17.5911 18.1927 17.3359 18.1016C17.099 18.0104 16.8529 17.8919 16.5977 17.7461C16.4154 17.6367 16.1693 17.4727 15.8594 17.2539C15.4948 16.9805 15.2396 16.7982 15.0938 16.707C14.474 16.306 13.7083 15.7956 12.7969 15.1758C11.8672 14.5378 11.1198 14.0365 10.5547 13.6719C10.1536 13.3984 9.87109 13.1341 9.70703 12.8789C9.5612 12.6602 9.50651 12.4414 9.54297 12.2227C9.57943 12.0221 9.69792 11.8034 9.89844 11.5664C10.0078 11.4206 10.2174 11.2018 10.5273 10.9102C10.6367 10.819 10.7188 10.7461 10.7734 10.6914C10.8646 10.6003 10.9375 10.5182 10.9922 10.4453C11.0286 10.4089 11.5482 9.92578 12.5508 8.99609C13.681 7.95703 14.5469 7.13672 15.1484 6.53516C16.0781 5.6237 16.5612 5.10417 16.5977 4.97656C16.5977 4.9401 16.5977 4.89453 16.5977 4.83984C16.5794 4.7487 16.543 4.67578 16.4883 4.62109C16.4336 4.58464 16.3698 4.56641 16.2969 4.56641C16.2422 4.56641 16.1693 4.57552 16.0781 4.59375C15.987 4.61198 15.2305 5.09505 13.8086 6.04297C12.4049 6.95443 10.3086 8.34896 7.51953 10.2266C7.11849 10.5182 6.73568 10.7279 6.37109 10.8555C6.00651 10.9831 5.66016 11.0469 5.33203 11.0469C5.00391 11.0469 4.52083 10.9648 3.88281 10.8008C3.3724 10.6732 2.80729 10.5091 2.1875 10.3086C2.27865 10.3451 2.04167 10.2721 1.47656 10.0898C1.22135 9.9987 1.02083 9.92578 0.875 9.87109C0.692708 9.79818 0.53776 9.72526 0.410156 9.65234C0.264323 9.57943 0.164062 9.48828 0.109375 9.37891C0.0364583 9.28776 0 9.17839 0 9.05078C0 9.03255 0 9.02344 0 9.02344C0 9.00521 0 8.98698 0 8.96875C0.0182292 8.78646 0.154948 8.60417 0.410156 8.42188C0.665365 8.23958 1.02083 8.04818 1.47656 7.84766Z"#
static let chevronDown = #"M11.8854 11.6408C11.3964 12.1197 10.6036 12.1197 10.1145 11.6408L0.366765 2.09366C-0.122255 1.61471 -0.122255 0.838169 0.366765 0.359215C0.855786 -0.119739 1.64864 -0.119739 2.13767 0.359215L11 9.03912L19.8623 0.359215C20.3514 -0.119739 21.1442 -0.119739 21.6332 0.359215C22.1223 0.838169 22.1223 1.61471 21.6332 2.09366L11.8854 11.6408Z"#
static let replyArrow = #"M7.73438 12.6367C7.8125 12.5586 7.87109 12.4674 7.91016 12.3633C7.94922 12.2721 7.96875 12.168 7.96875 12.0508V9.375C9.375 9.375 10.5599 9.58333 11.5234 10C12.6172 10.4948 13.4635 11.276 14.0625 12.3438C14.1536 12.513 14.2773 12.6432 14.4336 12.7344C14.5768 12.8255 14.7526 12.8711 14.9609 12.8711C15.1172 12.8711 15.2604 12.819 15.3906 12.7148C15.4948 12.6237 15.5729 12.5065 15.625 12.3633C15.6771 12.2201 15.7031 12.0768 15.7031 11.9336C15.7031 10.6185 15.5599 9.45312 15.2734 8.4375C14.974 7.39583 14.5182 6.51042 13.9062 5.78125C13.6068 5.41667 13.2617 5.09115 12.8711 4.80469C12.4805 4.53125 12.0508 4.29688 11.582 4.10156C11.0482 3.88021 10.4557 3.72396 9.80469 3.63281C9.27083 3.55469 8.65885 3.51562 7.96875 3.51562V0.859375C7.96875 0.703125 7.92969 0.559896 7.85156 0.429688C7.78646 0.299479 7.68229 0.195312 7.53906 0.117188C7.40885 0.0390625 7.26562 0 7.10938 0C6.92708 0 6.74479 0.0520833 6.5625 0.15625C6.43229 0.234375 6.28255 0.351562 6.11328 0.507812L0.371094 5.68359C0.292969 5.7487 0.234375 5.8138 0.195312 5.87891C0.143229 5.94401 0.104167 6.00911 0.078125 6.07422C0.0520833 6.13932 0.0325521 6.19792 0.0195312 6.25C0.00651042 6.3151 0 6.3737 0 6.42578C0 6.49089 0.00651042 6.55599 0.0195312 6.62109C0.0325521 6.67318 0.0520833 6.73177 0.078125 6.79688C0.104167 6.86198 0.143229 6.92708 0.195312 6.99219C0.234375 7.05729 0.292969 7.1224 0.371094 7.1875L6.11328 12.4023C6.29557 12.5716 6.46484 12.6888 6.62109 12.7539C6.69922 12.793 6.77734 12.819 6.85547 12.832C6.94661 12.8581 7.03125 12.8711 7.10938 12.8711C7.22656 12.8711 7.33724 12.8516 7.44141 12.8125C7.55859 12.7734 7.65625 12.7148 7.73438 12.6367Z"#
static let microphone = #"M3.69141 5.09766C3.69141 4.16016 3.91602 3.30078 4.36523 2.51953C4.79492 1.75781 5.38086 1.14258 6.12305 0.673828C6.88477 0.224609 7.70508 0 8.58398 0C9.44336 0 10.2441 0.214844 10.9863 0.644531C11.7285 1.07422 12.3145 1.66016 12.7441 2.40234C13.1934 3.16406 13.4375 3.98438 13.4766 4.86328V5.09766V10.8105C13.4766 11.748 13.252 12.6074 12.8027 13.3887C12.373 14.1504 11.7871 14.7559 11.0449 15.2051C10.2832 15.6738 9.46289 15.9082 8.58398 15.9082C7.72461 15.9082 6.92383 15.6934 6.18164 15.2637C5.43945 14.834 4.85352 14.248 4.42383 13.5059C3.97461 12.7441 3.73047 11.9238 3.69141 11.0449V10.8105V5.09766ZM8.58398 1.58203C7.99805 1.58203 7.45117 1.72852 6.94336 2.02148C6.43555 2.31445 6.03516 2.71484 5.74219 3.22266C5.42969 3.73047 5.25391 4.28711 5.21484 4.89258V5.09766V10.8105C5.21484 11.4551 5.37109 12.0508 5.68359 12.5977C5.97656 13.125 6.37695 13.5449 6.88477 13.8574C7.41211 14.1699 7.97852 14.3262 8.58398 14.3262C9.16992 14.3262 9.7168 14.1797 10.2246 13.8867C10.7324 13.5938 11.1328 13.1934 11.4258 12.6855C11.7383 12.1777 11.9141 11.6211 11.9531 11.0156V10.8105V5.09766C11.9531 4.45312 11.7969 3.85742 11.4844 3.31055C11.1914 2.7832 10.791 2.36328 10.2832 2.05078C9.75586 1.73828 9.18945 1.58203 8.58398 1.58203ZM9.3457 19.7168V22.7637C9.3457 22.9785 9.26758 23.1641 9.11133 23.3203C8.97461 23.4766 8.79883 23.5547 8.58398 23.5547C8.38867 23.5547 8.22266 23.4863 8.08594 23.3496C7.92969 23.2324 7.8418 23.0762 7.82227 22.8809V22.7637V19.7168C6.74805 19.5996 5.72266 19.2969 4.74609 18.8086C3.80859 18.3203 2.98828 17.666 2.28516 16.8457C1.5625 16.0449 1.00586 15.1367 0.615234 14.1211C0.205078 13.0664 0 11.9629 0 10.8105C0 10.5957 0.078125 10.4102 0.234375 10.2539C0.390625 10.0977 0.566406 10.0195 0.761719 10.0195C0.976562 10.0195 1.16211 10.0977 1.31836 10.2539C1.45508 10.4102 1.52344 10.5957 1.52344 10.8105C1.52344 11.8066 1.70898 12.7637 2.08008 13.6816C2.45117 14.5605 2.95898 15.332 3.60352 15.9961C4.24805 16.6797 4.99023 17.207 5.83008 17.5781C6.70898 17.9688 7.62695 18.1641 8.58398 18.1641C9.54102 18.1641 10.459 17.9688 11.3379 17.5781C12.1777 17.207 12.9199 16.6797 13.5645 15.9961C14.209 15.332 14.7168 14.5605 15.0879 13.6816C15.459 12.7637 15.6445 11.8066 15.6445 10.8105C15.6445 10.5957 15.7129 10.4102 15.8496 10.2539C16.0059 10.0977 16.1914 10.0195 16.4062 10.0195C16.6016 10.0195 16.7773 10.0977 16.9336 10.2539C17.0898 10.4102 17.168 10.5957 17.168 10.8105C17.168 11.9629 16.9629 13.0664 16.5527 14.1211C16.1621 15.1367 15.6055 16.0449 14.8828 16.8457C14.1797 17.666 13.3594 18.3203 12.4219 18.8086C11.4453 19.2969 10.4199 19.5996 9.3457 19.7168Z"#
}
/// Sets initial scroll position to bottom.
/// iOS 18+: `.initialOffset` only don't re-anchor on container size changes
/// (keyboard open/close causes jumps when re-anchoring).
/// iOS 17: `.defaultScrollAnchor(.bottom)` (no role API available).
private struct DefaultScrollAnchorModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 18, *) {
content.defaultScrollAnchor(.bottom, for: .initialOffset)
} else {
content.defaultScrollAnchor(.bottom)
}
}
}
/// Composer overlay pinned to the bottom of the container.
/// Keyboard offset is handled by KeyboardSyncedContainer (iOS < 26) or
/// SwiftUI native keyboard handling (iOS 26+) no manual padding needed.
private struct ComposerOverlay<C: View>: View {
let composer: C
@Binding var composerHeight: CGFloat
var body: some View {
composer
.background(
GeometryReader { geo in
Color.clear.preference(key: ComposerHeightKey.self, value: geo.size.height)
}
)
.frame(maxHeight: .infinity, alignment: .bottom)
}
}
/// iOS 26: scroll edge blur is on by default in inverted scroll (scaleEffect y: -1)
/// both top+bottom edge effects overlap and blur the entire screen.
/// Hide only the ScrollView's top edge (= visual bottom after inversion, near composer).
/// Keep ScrollView's bottom edge (= visual top after inversion, near nav bar) for a
/// nice fade effect when scrolling through older messages.
private struct DisableScrollEdgeEffectModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26, *) {
content.scrollEdgeEffectHidden(true, for: .top)
} else {
content
}
}
}
/// iOS < 26: UIKit transform on listController.view handles scroll inversion.
/// iOS 26+: SwiftUI scaleEffect (no UIKit container, native keyboard handling).
private struct ScrollInversionModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26, *) {
content.scaleEffect(x: 1, y: -1)
} else {
content // UIKit CGAffineTransform(scaleX: 1, y: -1) on listController.view
}
}
}
/// Counteracts the UIKit y-flip on listController.view for iOS < 26.
/// Elements that should appear screen-relative (gradients, backgrounds,
/// buttons) use this to flip back to normal orientation.
/// iOS 26+: no UIKit flip, no counter needed passthrough.
private struct CounterUIKitFlipModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26, *) {
content
} else {
content.scaleEffect(x: 1, y: -1)
}
}
}
// MARK: - ForwardedPhotoCollageView
/// Telegram-style collage layout for forwarded image attachments (same patterns as PhotoCollageView).
/// Uses ForwardedImagePreviewCell for each cell instead of MessageImageView.
struct ForwardedPhotoCollageView: View {
let attachments: [ReplyAttachmentData]
let outgoing: Bool
let maxWidth: CGFloat
var onImageTap: ((String) -> Void)?
private let spacing: CGFloat = 2
private let maxCollageHeight: CGFloat = 320
var body: some View {
collageContent(contentWidth: maxWidth)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
@ViewBuilder
private func collageContent(contentWidth: CGFloat) -> some View {
switch attachments.count {
case 0:
EmptyView()
case 1:
cell(attachments[0], width: contentWidth, height: min(contentWidth * 0.75, maxCollageHeight))
case 2:
let cellWidth = (contentWidth - spacing) / 2
let cellHeight = min(cellWidth * 1.2, maxCollageHeight)
HStack(spacing: spacing) {
cell(attachments[0], width: cellWidth, height: cellHeight)
cell(attachments[1], width: cellWidth, height: cellHeight)
}
case 3:
let rightWidth = contentWidth * 0.34
let leftWidth = contentWidth - spacing - rightWidth
let totalHeight = min(leftWidth * 1.1, maxCollageHeight)
let rightCellHeight = (totalHeight - spacing) / 2
HStack(spacing: spacing) {
cell(attachments[0], width: leftWidth, height: totalHeight)
VStack(spacing: spacing) {
cell(attachments[1], width: rightWidth, height: rightCellHeight)
cell(attachments[2], width: rightWidth, height: rightCellHeight)
}
}
case 4:
let cellWidth = (contentWidth - spacing) / 2
let cellHeight = min(cellWidth * 0.85, maxCollageHeight / 2)
VStack(spacing: spacing) {
HStack(spacing: spacing) {
cell(attachments[0], width: cellWidth, height: cellHeight)
cell(attachments[1], width: cellWidth, height: cellHeight)
}
HStack(spacing: spacing) {
cell(attachments[2], width: cellWidth, height: cellHeight)
cell(attachments[3], width: cellWidth, height: cellHeight)
}
}
default:
let topCellWidth = (contentWidth - spacing) / 2
let bottomCellWidth = (contentWidth - spacing * 2) / 3
let topHeight = min(topCellWidth * 0.85, maxCollageHeight * 0.55)
let bottomHeight = min(bottomCellWidth * 0.85, maxCollageHeight * 0.45)
VStack(spacing: spacing) {
HStack(spacing: spacing) {
cell(attachments[0], width: topCellWidth, height: topHeight)
cell(attachments[1], width: topCellWidth, height: topHeight)
}
HStack(spacing: spacing) {
cell(attachments[2], width: bottomCellWidth, height: bottomHeight)
if attachments.count > 3 {
cell(attachments[3], width: bottomCellWidth, height: bottomHeight)
}
if attachments.count > 4 {
cell(attachments[4], width: bottomCellWidth, height: bottomHeight)
}
}
}
}
}
@ViewBuilder
private func cell(_ attachment: ReplyAttachmentData, width: CGFloat, height: CGFloat) -> some View {
ForwardedImagePreviewCell(
attachment: attachment,
width: width,
fixedHeight: height,
outgoing: outgoing,
onTapCachedImage: { onImageTap?(attachment.id) }
)
.frame(width: width, height: height)
.clipped()
}
}
// MARK: - ForwardedImagePreviewCell
/// Proper View struct for forwarded image previews uses `@State` + `.task` so the image
/// updates when `AttachmentCache` is populated by `MessageImageView` downloading the original.
/// Without this, a plain `let cachedImage = ...` in the parent body is a one-shot evaluation
/// that never re-checks the cache.
struct ForwardedImagePreviewCell: View {
let attachment: ReplyAttachmentData
let width: CGFloat
var fixedHeight: CGFloat?
let outgoing: Bool
let onTapCachedImage: () -> Void
@State private var cachedImage: UIImage?
@State private var blurImage: UIImage?
private var imageHeight: CGFloat { fixedHeight ?? min(width * 0.75, 200) }
var body: some View {
Group {
if let image = cachedImage {
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(width: width, height: imageHeight)
.clipped()
.contentShape(Rectangle())
.onTapGesture { onTapCachedImage() }
} else if let blur = blurImage {
Image(uiImage: blur)
.resizable()
.scaledToFill()
.frame(width: width, height: imageHeight)
.clipped()
} else {
Rectangle()
.fill(outgoing ? Color.white.opacity(0.08) : Color.white.opacity(0.06))
.frame(width: width, height: imageHeight)
.overlay {
Image(systemName: "photo")
.font(.system(size: 24))
.foregroundStyle(
outgoing
? Color.white.opacity(0.3)
: RosettaColors.Adaptive.textSecondary.opacity(0.5)
)
}
}
}
.task {
// Decode blurhash from preview field ("cdnTag::blurhash" or just "blurhash").
if let hash = extractBlurHash(), !hash.isEmpty {
blurImage = MessageCellView.cachedBlurHash(hash, width: 64, height: 64)
}
// Check cache immediately image may already be there.
if let img = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
cachedImage = img
return
}
// Retry: the original MessageImageView may still be downloading.
// Poll up to 5 times with 500ms intervals (2.5s total) covers most download durations.
for _ in 0..<5 {
try? await Task.sleep(for: .milliseconds(500))
if Task.isCancelled { return }
if let img = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
cachedImage = img
return
}
}
}
}
private func extractBlurHash() -> String? {
guard !attachment.preview.isEmpty else { return nil }
let parts = attachment.preview.components(separatedBy: "::")
let hash = parts.count > 1 ? parts[1] : attachment.preview
return hash.isEmpty ? nil : hash
}
}
// MARK: - ReplyQuoteThumbnail
/// 32×32 thumbnail for reply quote views. Checks `AttachmentCache` first for the actual
/// downloaded image, falling back to blurhash. Uses `@State` + `.task` with retry polling
/// so the thumbnail updates when the original `MessageImageView` finishes downloading.
struct ReplyQuoteThumbnail: View {
let attachment: ReplyAttachmentData
let blurHash: String?
/// Actual cached image (from AttachmentCache). Overrides blurhash when available.
@State private var cachedImage: UIImage?
var body: some View {
// Blurhash is computed synchronously (static cache) so it shows on the first frame.
// cachedImage overrides it when the real photo is available in AttachmentCache.
let image = cachedImage ?? blurHash.flatMap {
MessageCellView.cachedBlurHash($0, width: 32, height: 32)
}
Group {
if let image {
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 4))
}
}
.task {
// Check AttachmentCache for the actual downloaded photo.
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
cachedImage = cached
return
}
// Retry image may be downloading in MessageImageView.
for _ in 0..<5 {
try? await Task.sleep(for: .milliseconds(500))
if Task.isCancelled { return }
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
cachedImage = cached
return
}
}
}
}
}
#Preview {
NavigationStack {
ZStack {
Color.black.ignoresSafeArea()
ChatDetailView(
route: ChatRoute(
publicKey: "demo_public_key",
title: "Demo User",
username: "demo",
verified: 0
)
)
}
.preferredColorScheme(.dark)
}
}