2238 lines
114 KiB
Swift
2238 lines
114 KiB
Swift
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)
|
||
}
|
||
}
|