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

1912 lines
96 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import UIKit
import UserNotifications
/// Measures the composer height so the scroll can reserve bottom space.
private struct ComposerHeightKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
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
@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 callErrorMessage: String?
@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?
/// Triggers NativeMessageList to scroll to bottom (button tap).
@State private var scrollToBottomRequested = false
/// 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 maxBubbleWidth: CGFloat {
let screenWidth = UIScreen.main.bounds.width
let listHorizontalInsets: CGFloat = 20 // NativeMessageList section insets: leading/trailing 10
let bubbleHorizontalMargins: CGFloat = 16 // 8pt left + 8pt right bubble lane reserves
let availableWidth = max(40, screenWidth - listHorizontalInsets - bubbleHorizontalMargins)
// Telegram ChatMessageItemWidthFill:
// compactInset = 36, compactWidthBoundary = 500, freeMaximumFillFactor = 0.85/0.65.
let compactInset: CGFloat = 36
let freeFillFactor: CGFloat = screenWidth > 680 ? 0.65 : 0.85
let widthByInset = availableWidth - compactInset
let widthByFactor = availableWidth * freeFillFactor
let width = min(widthByInset, widthByFactor)
return max(40, width)
}
/// Visual chat content: messages list + gradient overlays + background.
/// No parent UIKit flip NativeMessageListController manages its own collectionView transform.
@ViewBuilder
private var chatArea: some View {
ZStack {
messagesList(maxBubbleWidth: maxBubbleWidth)
}
.overlay {
chatEdgeGradients
}
// FPS overlay uncomment for performance testing:
// .overlay { FPSOverlayView() }
.background {
ZStack {
RosettaColors.Adaptive.background
tiledChatBackground
}
.ignoresSafeArea()
}
}
@ViewBuilder
private var content: some View {
let _ = PerformanceLogger.shared.track("chatDetail.bodyEval")
// iOS 26+: SwiftUI handles keyboard natively ComposerOverlay.
// iOS < 26: Composer embedded in NativeMessageListController via UIHostingController
// pinned to keyboardLayoutGuide frame-perfect keyboard sync (Telegram-style).
Group {
if #available(iOS 26, *) {
chatArea
.overlay {
if !route.isSystemAccount {
ComposerOverlay(
composer: composer,
composerHeight: $composerHeight
)
}
}
.onPreferenceChange(ComposerHeightKey.self) { newHeight in
composerHeight = newHeight
}
} else {
// iOS < 26: composer is inside NativeMessageListController.
// UIKit handles ALL keyboard/safe area insets manually via
// contentInsetAdjustmentBehavior = .never + applyInsets().
// Tell SwiftUI to not adjust frame for ANY safe area edge
// this ensures keyboardWillChangeFrameNotification reaches
// the embedded controller without interference.
chatArea
.ignoresSafeArea()
}
}
.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
Task { @MainActor in
guard await viewModel.ensureMessageLoaded(messageId: msgId) else { return }
scrollToMessageId = msgId
}
}
cellActions.onRetry = { [self] msg in retryMessage(msg) }
cellActions.onRemove = { [self] msg in removeMessage(msg) }
cellActions.onCall = { [self] peerKey in
let peerTitle = dialog?.opponentTitle ?? route.title
let peerUsername = dialog?.opponentUsername ?? route.username
let result = CallManager.shared.startOutgoingCall(
toPublicKey: peerKey, title: peerTitle, username: peerUsername
)
if case .alreadyInCall = result { callErrorMessage = "You are already in another call." }
}
// 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)
SessionManager.shared.resetIdleTimer()
updateReadEligibility()
clearDeliveredNotifications(for: route.publicKey)
// Telegram-like read policy: mark read only when dialog is truly readable
// (view active + list at bottom).
markDialogAsRead()
// 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()
updateReadEligibility()
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 {
firstUnreadMessageId = nil
// Flush final read only if dialog is still eligible at the moment of closing.
markDialogAsRead()
isViewActive = false
updateReadEligibility()
MessageRepository.shared.setDialogActive(route.publicKey, isActive: false)
SessionManager.shared.stopIdleTimer()
// Desktop parity: save draft text on chat close.
DraftManager.shared.saveDraft(for: route.publicKey, text: messageText)
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
// Re-evaluate read eligibility after app returns from background.
// readEligibleDialogs is cleared on didEnterBackground (SessionManager);
// this restores eligibility for the currently-visible chat.
// 600ms delay lets notification-tap navigation settle if user tapped
// a notification for a DIFFERENT chat, isViewActive becomes false.
guard isViewActive else { return }
SessionManager.shared.resetIdleTimer()
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(600))
guard isViewActive else { return }
updateReadEligibility()
markDialogAsRead()
}
}
}
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.")
}
.alert("Call Error", isPresented: Binding(
get: { callErrorMessage != nil },
set: { isPresented in
if !isPresented {
callErrorMessage = nil
}
}
)) {
Button("OK", role: .cancel) {
callErrorMessage = nil
}
} message: {
Text(callErrorMessage ?? "Failed to start call.")
}
.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) {
HStack(spacing: 8) {
if canStartCall {
Button { startVoiceCall() } label: {
Image(systemName: "phone.fill")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 36, height: 36)
.contentShape(Circle())
.background {
glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white)
}
}
.buttonStyle(.plain)
.accessibilityLabel("Start Call")
}
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) {
HStack(spacing: 8) {
if canStartCall {
Button { startVoiceCall() } label: {
Image(systemName: "phone.fill")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 44, height: 44)
.contentShape(Circle())
.background {
glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white)
}
}
.buttonStyle(.plain)
.accessibilityLabel("Start Call")
}
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)
}
private var canStartCall: Bool {
!route.isSavedMessages
&& !route.isSystemAccount
&& !DatabaseManager.isGroupDialogKey(route.publicKey)
}
private func startVoiceCall() {
let peerTitle = dialog?.opponentTitle ?? route.title
let peerUsername = dialog?.opponentUsername ?? route.username
let result = CallManager.shared.startOutgoingCall(
toPublicKey: route.publicKey,
title: peerTitle,
username: peerUsername
)
switch result {
case .started:
break
case .alreadyInCall:
callErrorMessage = "You are already in another call."
case .accountNotBound:
callErrorMessage = "Account is not ready for calls yet."
case .invalidTarget:
callErrorMessage = "Unable to start call for this chat."
case .notIncoming:
callErrorMessage = "Call state is invalid."
}
}
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
/// Default chat wallpaper full-screen scaled image.
private var tiledChatBackground: some View {
Image("ChatWallpaper")
.resizable()
.aspectRatio(contentMode: .fill)
}
// 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 #available(iOS 26, *), messages.isEmpty {
// iOS 26+: ComposerOverlay is always added in `content`, so emptyStateView alone is fine.
emptyStateView
} else {
// iOS < 26 empty: NativeMessageListController shows empty state + composer (UIKit).
// iOS < 26 / 26+ non-empty: normal message list.
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)
}
.padding(.horizontal, 24)
.padding(.vertical, 20)
.background {
glass(shape: .rounded(20), strokeOpacity: 0.18)
}
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 {
let useComposer: Bool = {
if #available(iOS 26, *) { return false }
return !route.isSystemAccount
}()
// Reply info for ComposerView
let replySender: String? = replyingToMessage.map { senderDisplayName(for: $0.fromPublicKey) }
let replyPreview: String? = replyingToMessage.map { replyPreviewText(for: $0) }
NativeMessageListView(
messages: messages,
maxBubbleWidth: maxBubbleWidth,
currentPublicKey: currentPublicKey,
highlightedMessageId: highlightedMessageId,
route: route,
actions: cellActions,
hasMoreMessages: viewModel.hasMoreMessages,
firstUnreadMessageId: firstUnreadMessageId,
useUIKitComposer: useComposer,
emptyChatInfo: useComposer ? EmptyChatInfo(
title: titleText,
subtitle: subtitleText,
initials: avatarInitials,
colorIndex: avatarColorIndex,
isOnline: dialog?.isOnline ?? false,
isSavedMessages: route.isSavedMessages,
avatarImage: opponentAvatar
) : nil,
scrollToMessageId: scrollToMessageId,
shouldScrollToBottom: shouldScrollOnNextMessage,
scrollToBottomRequested: $scrollToBottomRequested,
onAtBottomChange: { atBottom in
isAtBottom = atBottom
SessionManager.shared.resetIdleTimer()
updateReadEligibility()
if atBottom {
markDialogAsRead()
}
},
onPaginate: {
Task { await viewModel.loadMore() }
},
onTapBackground: {
isInputFocused = false
},
onNewMessageAutoScroll: {
shouldScrollOnNextMessage = false
let lastIsOutgoing = messages.last?.isFromMe(myPublicKey: currentPublicKey) == true
if isViewActive && !lastIsOutgoing
&& !route.isSavedMessages && !route.isSystemAccount {
updateReadEligibility()
markDialogAsRead()
}
},
onComposerHeightChange: { composerHeight = $0 },
onKeyboardDidHide: { isInputFocused = false },
messageText: $messageText,
isInputFocused: $isInputFocused,
replySenderName: replySender,
replyPreviewText: replyPreview,
onSend: sendCurrentMessage,
onAttach: { showAttachmentPanel = true },
onTyping: handleComposerUserTyping,
onReplyCancel: { withAnimation(.easeOut(duration: 0.15)) { replyingToMessage = nil } }
)
.onChange(of: scrollToMessageId) { _, targetId in
guard let targetId else { return }
// NativeMessageListView handles the actual scroll via scrollToMessageId param.
// Here we only manage the highlight animation.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
scrollToMessageId = nil
withAnimation(.easeIn(duration: 0.2)) { highlightedMessageId = targetId }
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
withAnimation(.easeOut(duration: 0.5)) { highlightedMessageId = nil }
}
}
}
}
// Scroll-to-bottom button moved to UIKit (NativeMessageListController)
// pinned to composer via Auto Layout constraint for pixel-perfect sync.
// Message row rendering extracted to MessageCellView (Equatable, .equatable() modifier).
// See MessageCellView.swift.
// 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
// Phase 7: Route KVO to NativeMessageListController via NotificationCenter
// for interactive keyboard dismiss (finger tracking)
NotificationCenter.default.post(
name: NSNotification.Name("InteractiveKeyboardHeightChanged"),
object: nil,
userInfo: ["height": height]
)
},
onUserTextInsertion: handleComposerUserTyping,
onMultilineChange: { multiline in
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: - 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
/// Extract reply preview text for ComposerView (same logic as SwiftUI replyBar).
///
/// Priority: attachment type label first, then clean caption text.
/// Previous version checked `message.text` BEFORE attachment type which caused empty
/// previews when text contained invisible/encrypted characters for photo/file messages.
func replyPreviewText(for message: ChatMessage) -> String {
// 1. Determine attachment type label
let attachmentLabel: String? = {
for att in message.attachments {
switch att.type {
case .image: return "Photo"
case .file:
let parsed = AttachmentPreviewCodec.parseFilePreview(att.preview)
if !parsed.fileName.isEmpty { return parsed.fileName }
return att.id.isEmpty ? "File" : att.id
case .avatar: return "Avatar"
case .messages: return "Forwarded message"
case .call: return "Call"
}
}
return nil
}()
// 2. Clean caption strip invisible chars (zero-width spaces, encrypted residue)
let visibleText: String = {
let stripped = message.text
.trimmingCharacters(in: .whitespacesAndNewlines)
.filter { !$0.isASCII || $0.asciiValue! >= 0x20 } // drop control chars
// Extra guard: if text looks like encrypted payload, ignore it
if MessageCellLayout.isGarbageOrEncrypted(stripped) { return "" }
return stripped
}()
// 3. For image/file with non-empty caption: show caption
if attachmentLabel != nil, !visibleText.isEmpty { return visibleText }
// 4. For image/file with no caption: show type label
if let label = attachmentLabel { return label }
// 5. No attachment: show text
if !visibleText.isEmpty { return message.text }
return ""
}
@ViewBuilder
func replyBar(for message: ChatMessage) -> some View {
let senderName = senderDisplayName(for: message.fromPublicKey)
let previewText = replyPreviewText(for: message)
HStack(spacing: 0) {
RoundedRectangle(cornerRadius: 1.0)
.fill(RosettaColors.figmaBlue)
.frame(width: 2, height: 36)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 0) {
Text("Reply to ")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.white)
Text(senderName)
.font(.system(size: 14, weight: .medium))
.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: 12, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.frame(width: 44, height: 44)
}
}
.padding(.leading, 8) // align blue line with text cursor (6pt textView padding + 2pt inset)
.padding(.trailing, 0)
.padding(.top, 8)
.padding(.bottom, 2) // tight gap to text input below
.transition(.move(edge: .bottom).combined(with: .opacity))
}
// MARK: - Forward
func forwardMessage(_ message: ChatMessage, to targetRoute: ChatRoute) {
// 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 isForward,
let att = replyAttachment,
let innerMessages = (parseReplyBlob(att.blob) ?? parseReplyBlob(att.preview)),
!innerMessages.isEmpty {
// Unwrap: forward the original messages, not the wrapper
forwardDataList = innerMessages
} else {
// Regular message forward as-is
forwardDataList = [buildReplyData(from: message)]
}
// Desktop commit aaa4b42: no re-upload needed.
// chacha_key_plain in ReplyMessageData carries the original key,
// so the recipient can decrypt original CDN blobs directly.
let targetKey = targetRoute.publicKey
let targetTitle = targetRoute.title
let targetUsername = targetRoute.username
Task { @MainActor in
do {
try await SessionManager.shared.sendMessageWithReply(
text: "",
replyMessages: forwardDataList,
toPublicKey: targetKey,
opponentTitle: targetTitle,
opponentUsername: targetUsername
)
} catch {
sendError = "Failed to forward message"
}
}
}
/// Builds a `ReplyMessageData` from a `ChatMessage` for reply/forward encoding.
/// Desktop parity: `MessageReply` in `useReplyMessages.ts`.
/// Desktop commit `aaa4b42`: includes `chacha_key_plain` (hex key) + per-attachment transport.
private func buildReplyData(from message: ChatMessage) -> ReplyMessageData {
// Convert ChatMessage attachments to ReplyAttachmentData with transport info
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)
transport: ReplyAttachmentTransport(
transport_tag: att.transportTag,
transport_server: att.transportServer
)
)
}
// 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 {
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
// Extract hex key from "rawkey:<hex>" format for chacha_key_plain
let hexKey: String
if let password = message.attachmentPassword, password.hasPrefix("rawkey:") {
hexKey = String(password.dropFirst("rawkey:".count))
} else {
hexKey = ""
}
return ReplyMessageData(
message_id: message.id,
publicKey: message.fromPublicKey,
message: cleanText,
timestamp: message.timestamp,
attachments: replyAttachments,
chacha_key_plain: hexKey
)
}
func requestUserInfoIfNeeded() {
// Always request we need fresh online status even if title is already populated.
SessionManager.shared.requestUserInfoIfNeeded(forKey: route.publicKey)
}
/// Dialog is readable only when this screen is active and list is at bottom.
func updateReadEligibility() {
MessageRepository.shared.setDialogReadEligible(
route.publicKey,
isEligible: isViewActive && isAtBottom
)
}
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)
updateReadEligibility()
}
func markDialogAsRead() {
guard MessageRepository.shared.isDialogReadEligible(route.publicKey) else { return }
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)
}
}
// 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)
}
// Fast path: memory cache only (no disk/crypto on UI path).
if let img = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) {
cachedImage = img
return
}
// Slow path: one background disk/decrypt attempt.
await ImageLoadLimiter.shared.acquire()
let loaded = await Task.detached(priority: .utility) {
AttachmentCache.shared.loadImage(forAttachmentId: attachment.id)
}.value
await ImageLoadLimiter.shared.release()
if let loaded, !Task.isCancelled {
cachedImage = loaded
return
}
// Retry memory cache only: original MessageImageView may still be downloading.
for _ in 0..<5 {
try? await Task.sleep(for: .milliseconds(500))
if Task.isCancelled { return }
if let img = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) {
cachedImage = img
return
}
}
}
}
private func extractBlurHash() -> String? {
guard !attachment.preview.isEmpty else { return nil }
let hash = AttachmentPreviewCodec.blurHash(from: 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 {
// Fast path: memory cache only.
if let cached = AttachmentCache.shared.cachedImage(forAttachmentId: attachment.id) {
cachedImage = cached
return
}
// Slow path: one background disk/decrypt attempt.
await ImageLoadLimiter.shared.acquire()
let loaded = await Task.detached(priority: .utility) {
AttachmentCache.shared.loadImage(forAttachmentId: attachment.id)
}.value
await ImageLoadLimiter.shared.release()
if let loaded, !Task.isCancelled {
cachedImage = loaded
return
}
// Retry memory cache only image may still be downloading in MessageImageView.
for _ in 0..<5 {
try? await Task.sleep(for: .milliseconds(500))
if Task.isCancelled { return }
if let cached = AttachmentCache.shared.cachedImage(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)
}
}