import CoreTransferable import Foundation import ImageIO import PhotosUI import SwiftUI import UniformTypeIdentifiers #if os(iOS) import UIKit #endif private enum EmojiPickerPresentation: Identifiable { case reaction(ChatMessage) case composer var id: String { switch self { case .reaction(let message): return "reaction-\(message.clientID)" case .composer: return "composer" } } } @MainActor struct ChatView: View { private static let bottomAnchorID = "luma-chat-timeline-bottom" private static let timelineCoordinateSpace = "luma-chat-timeline-space" @ObservedObject var model: AppModel let conversation: Conversation @StateObject private var audioRecorder = AudioMessageRecorder() @FocusState private var isComposerFocused: Bool @State private var draft = "" @State private var showingFileImporter = false @State private var fileImportMode: FileImportMode = .files @State private var showingEncryption = false @State private var showingVideoNoteRecorder = false @State private var videoNoteIsSending = false @State private var showingMediaPicker = false @State private var showingPhotoCamera = false @State private var photoCameraIsPreparingResult = false @State private var showingLocationPicker = false @State private var showingGroupInfo = false @State private var showingAttachmentPreview = false @State private var attachmentPreviewPresentationPending = false @State private var attachmentPreviewPresentationTask: Task? @State private var pickedMediaItems: [PhotosPickerItem] = [] @State private var attachmentDrafts: [AttachmentDraft] = [] @State private var isPreparingAttachments = false @State private var editingMessageID: String? @State private var replyingToMessageID: String? @State private var forwardingSelection: MessageForwardSelection? @State private var selectedMessageIDs: Set = [] @State private var destructiveAction: MessageDestructiveAction? @State private var replyThreadSelection: ReplyThreadSelection? @State private var emojiPickerPresentation: EmojiPickerPresentation? @State private var hasCompletedInitialScroll = false @State private var isNearTimelineBottom = true @State private var historyLoadAnchorID: String? @State private var historyTopTriggerArmed = true @State private var activeCaptureMode: ComposerCaptureMode? @State private var preparingCaptureMode: ComposerCaptureMode? @State private var captureGestureIsActive = false @State private var captureIsLocked = false @State private var captureDragTranslation: CGSize = .zero @State private var captureAttemptID = UUID() @State private var captureStartTask: Task? @State private var captureSuspendsArchiveSync = false var body: some View { VStack(spacing: 0) { if conversation.isGroup, !liveConversation.isGroupJoined { groupJoinBanner } messageTimeline .safeAreaInset(edge: .bottom, spacing: 0) { if isSelectingMessages { selectionActionBar } else { composer } } } .background(Color.secondary.opacity(0.035)) .navigationTitle("") // #if os(iOS) // .navigationBarTitleDisplayMode(.inline) // #endif .toolbar { ToolbarItem(placement: .principal) { chatNavigationTitle } if isSelectingMessages { ToolbarItem(placement: .cancellationAction) { Button("Отмена", action: clearMessageSelection) } ToolbarItem(placement: .primaryAction) { Text("Выбрано: \(selectedTimelineMessages.count)") .font(.subheadline.weight(.semibold)) } } else { ToolbarItem(placement: .primaryAction) { HStack { AvatarView( conversation: liveConversation, imageData: model.avatarData(for: conversation.jid), size: 28 ).padding(4) if !conversation.isGroup { Menu { Button { Task { await model.startCall( to: conversation.jid, withVideo: false) } } label: { Label("Аудиозвонок", systemImage: "phone") } .disabled(!canStartCall) .help("Аудиозвонок") Button { Task { await model.startCall(to: conversation.jid, withVideo: true) } } label: { Label("Видеозвонок", systemImage: "video") } .disabled(!canStartCall) .help("Видеозвонок") encryptionMenu } label: { Image(systemName: "ellipsis.circle") } .disabled(!canStartCall) } if conversation.isGroup { Button { showingGroupInfo = true } label: { Image(systemName: "person.3") } .help("Информация о группе") } } } } } #if os(iOS) .toolbar(replyThreadSelection == nil ? .visible : .hidden, for: .navigationBar) #endif .fileImporter( isPresented: $showingFileImporter, allowedContentTypes: fileImportMode.allowedContentTypes, allowsMultipleSelection: true ) { result in guard case .success(let urls) = result else { return } let preferredKind = fileImportMode.preferredKind Task { await stageImportedFiles(urls, preferredKind: preferredKind) } } .photosPicker( isPresented: $showingMediaPicker, selection: $pickedMediaItems, maxSelectionCount: 20, matching: .any(of: [.images, .videos]), preferredItemEncoding: .current ) .onChange(of: showingMediaPicker) { _, isPresented in if !isPresented { schedulePendingAttachmentPreview() } } #if os(iOS) .fullScreenCover( isPresented: $showingPhotoCamera, onDismiss: { schedulePendingAttachmentPreview() if !photoCameraIsPreparingResult { releaseArchiveSyncAfterCapture() } } ) { SystemPhotoCameraView( onMedia: { result in showingPhotoCamera = false switch result { case .success(let media): photoCameraIsPreparingResult = true Task { await stageCapturedMedia(media) } case .failure(let error): model.errorMessage = error.localizedDescription } }, onCancel: { showingPhotoCamera = false } ) .ignoresSafeArea() } #endif .onChange(of: pickedMediaItems) { _, items in guard !items.isEmpty else { return } Task { await stagePickedMedia(items) } } .onChange(of: draft) { _, text in model.updateComposerActivity(text, in: liveConversation) } .sheet(isPresented: $showingEncryption) { EncryptionDevicesView(model: model, conversation: conversation) } .sheet( isPresented: $showingVideoNoteRecorder, onDismiss: { if !videoNoteIsSending { releaseArchiveSyncAfterCapture() } } ) { VideoNoteCaptureView { recording in sendRecordedVideoNote(recording) } } .sheet(isPresented: $showingLocationPicker) { LocationPickerView { location in Task { await model.sendLocation(location, to: conversation.jid) } } } .sheet( isPresented: $showingAttachmentPreview, onDismiss: { attachmentPreviewPresentationPending = false attachmentPreviewPresentationTask?.cancel() attachmentPreviewPresentationTask = nil } ) { AttachmentPreviewView( model: model, conversation: liveConversation, drafts: $attachmentDrafts ) } .sheet(isPresented: $showingGroupInfo) { GroupInfoView(model: model, conversation: liveConversation) } .sheet(item: $forwardingSelection) { selection in ForwardMessageView(model: model, messages: selection.messages) { clearMessageSelection() } } .sheet(item: $emojiPickerPresentation) { presentation in EmojiPickerView { emoji in handleEmojiSelection(emoji, for: presentation) } } .alert(item: $destructiveAction) { action in Alert( title: Text(action.title), message: Text(action.explanation), primaryButton: .destructive(Text(action.confirmationTitle)) { perform(action) }, secondaryButton: .cancel(Text("Отмена")) ) } .overlay { replyThreadOverlay } .onChange(of: conversation.jid) { _, _ in hasCompletedInitialScroll = false isNearTimelineBottom = true historyLoadAnchorID = nil historyTopTriggerArmed = true } .onDisappear { model.endComposerActivity(in: liveConversation) attachmentPreviewPresentationTask?.cancel() attachmentPreviewPresentationTask = nil attachmentPreviewPresentationPending = false replyThreadSelection = nil clearMessageSelection() cancelComposerRecording(feedback: false) if !showingVideoNoteRecorder, !showingPhotoCamera, !videoNoteIsSending, !photoCameraIsPreparingResult { releaseArchiveSyncAfterCapture() } if !showingAttachmentPreview { model.discardAttachmentDrafts(attachmentDrafts) attachmentDrafts = [] } } } @ViewBuilder private var replyThreadOverlay: some View { if let selection = replyThreadSelection, let root = model.message(withID: selection.rootID, in: conversation.jid) { ReplyThreadOverlay( model: model, rootMessage: root, replies: replyThreadReplies( for: root, selectedReplyID: selection.selectedReplyID ), selectedReplyID: selection.selectedReplyID, onDismiss: dismissReplyThread ) .zIndex(100) } } private var encryptionMenu: some View { Menu { Section(conversation.isGroup ? "Шифрование этой группы" : "Шифрование этого чата") { ForEach(EncryptionPreference.allCases, id: \.rawValue) { preference in Button { model.setEncryptionPreference(preference, for: conversation.jid) } label: { if model.encryptionPreference(for: conversation.jid) == preference { Label(preference.title, systemImage: "checkmark") } else { Text(preference.title) } } } } if !conversation.isGroup { Divider() Button { showingEncryption = true } label: { Label("Устройства и отпечатки", systemImage: "checkmark.shield") } } } label: { Label("Шифрование", systemImage: encryptionIcon) } .help(encryptionEnabled ? "OMEMO включено" : "Сообщения отправляются без OMEMO") } private var canStartCall: Bool { model.connectionStatus == .connected && model.activeCall == nil } private var selectedTimelineMessages: [ChatMessage] { model.selectedMessages.filter { selectedMessageIDs.contains($0.clientID) } } private var isSelectingMessages: Bool { !selectedMessageIDs.isEmpty } private var messageTimeline: some View { ScrollViewReader { proxy in let timelineEntries = model.selectedTimelineEntries GeometryReader { viewport in ZStack(alignment: .bottomTrailing) { ScrollView { LazyVStack(spacing: 5) { if hasCompletedInitialScroll, model.hasMoreOlderHistory, !timelineEntries.isEmpty { Color.clear .frame(height: 1) .accessibilityHidden(true) .onAppear { guard historyTopTriggerArmed, !model.isLoadingOlderHistory else { return } historyTopTriggerArmed = false historyLoadAnchorID = timelineEntries.first?.id model.loadOlderHistoryForSelectedConversation() } .onDisappear { historyTopTriggerArmed = true } if model.isLoadingOlderHistory { ProgressView() .controlSize(.small) .padding(.vertical, 6) } } if timelineEntries.isEmpty { ContentUnavailableView( emptyChatTitle, systemImage: conversation.isGroup ? "person.3.fill" : (encryptionEnabled ? "lock.shield" : "lock.open"), description: Text(emptyChatDescription) ) .padding(.top, 80) .onAppear { // Empty timeline has no scroll gesture, so load // the first page directly when it appears. guard model.hasMoreOlderHistory, !model.isLoadingOlderHistory else { return } model.loadOlderHistoryForSelectedConversation() } } else { ForEach(timelineEntries) { entry in let message = entry.message if entry.startsNewDay { datePill(message.timestamp) } MessageBubble( model: model, message: message, isSelectionMode: isSelectingMessages, isSelected: selectedMessageIDs.contains(message.clientID), onAttachmentTap: { Task { await model.previewAttachment(message) } }, onEdit: { startEditing(message) }, onReply: { startReplying(to: message) }, onForward: { presentForwarding([message]) }, onRetry: { Task { await model.retryMediaMessage(message) } }, onToggleSelection: { toggleMessageSelection(message) }, onBeginSelection: { beginMessageSelection(with: message) }, onRetract: { destructiveAction = .init( kind: .retract, messages: [message]) }, onDelete: { destructiveAction = .init( kind: .localDelete, messages: [message]) }, onReplyTap: { targetID in presentReplyThread( rootID: targetID, selectedReplyID: message.clientID ) }, onReact: { emoji in Task { await model.toggleReaction(emoji, on: message) } }, onReactPicker: { emojiPickerPresentation = .reaction(message) } ) .id(message.clientID) } } } .padding(.vertical, 10) timelineBottomSentinel #if os(iOS) ChatScrollMetricsObserver(identity: conversation.jid) { isNearBottom in guard isNearTimelineBottom != isNearBottom else { return } isNearTimelineBottom = isNearBottom } .frame(height: 0) .accessibilityHidden(true) #endif } .defaultScrollAnchor(.bottom) #if os(iOS) .scrollDismissesKeyboard(.interactively) #endif .coordinateSpace(name: Self.timelineCoordinateSpace) #if os(macOS) .onPreferenceChange(TimelineBottomYPreferenceKey.self) { bottomY in updateTimelineBottomProximity( bottomY: bottomY, viewportHeight: viewport.size.height ) } #endif .task(id: timelineEntries.last?.id) { await performInitialScrollAfterLayout(using: proxy) } .onChange(of: timelineEntries.last?.id) { oldID, newID in guard hasCompletedInitialScroll, let newID, newID != oldID, isNearTimelineBottom || timelineEntries.last?.message.direction == .outgoing else { return } scrollToBottom(proxy, animated: true) } .onChange(of: model.isLoadingOlderHistory) { wasLoading, isLoading in guard wasLoading, !isLoading, let anchor = historyLoadAnchorID else { return } Task { @MainActor in await Task.yield() proxy.scrollTo(anchor, anchor: .top) historyLoadAnchorID = nil } } .onChange(of: timelineEntries.count) { _, _ in selectedMessageIDs.formIntersection( Set(timelineEntries.map(\.id)) ) } if hasCompletedInitialScroll, !timelineEntries.isEmpty, !isNearTimelineBottom { jumpToLatestButton(using: proxy) .padding(.trailing, 14) .padding(.bottom, 12) .transition(.scale(scale: 0.82).combined(with: .opacity)) .zIndex(10) } } .animation( .easeInOut(duration: 0.16), value: isNearTimelineBottom ) } } } @ViewBuilder private var timelineBottomSentinel: some View { #if os(iOS) Color.clear .frame(height: 1) .accessibilityHidden(true) .id(Self.bottomAnchorID) #else Color.clear .frame(height: 1) .background { GeometryReader { geometry in Color.clear.preference( key: TimelineBottomYPreferenceKey.self, value: geometry.frame( in: .named(Self.timelineCoordinateSpace) ).maxY ) } } .accessibilityHidden(true) .id(Self.bottomAnchorID) #endif } private var selectionActionBar: some View { HStack(spacing: 18) { Text("Выбрано: \(selectedTimelineMessages.count)") .font(.subheadline.weight(.semibold)) .foregroundStyle(.secondary) Spacer() Button { presentForwarding(selectedTimelineMessages) } label: { Image(systemName: "arrowshape.turn.up.right.fill") .font(.system(size: 18, weight: .semibold)) .frame(width: 36, height: 36) } .buttonStyle(.plain) .disabled( selectedTimelineMessages.isEmpty || !selectedTimelineMessages.allSatisfy(\.canBeForwarded) ) .accessibilityLabel("Переслать выбранные сообщения") Menu { Button(role: .destructive) { destructiveAction = .init( kind: .localDelete, messages: selectedTimelineMessages ) } label: { Label("Удалить у меня", systemImage: "trash") } if !selectedTimelineMessages.isEmpty, selectedTimelineMessages.allSatisfy(\.canBeRetracted) { Button(role: .destructive) { destructiveAction = .init( kind: .retract, messages: selectedTimelineMessages ) } label: { Label("Удалить у всех", systemImage: "trash.slash") } } } label: { Image(systemName: "trash.fill") .font(.system(size: 18, weight: .semibold)) .foregroundStyle(.red) .frame(width: 36, height: 36) } .disabled(selectedTimelineMessages.isEmpty) .accessibilityLabel("Удалить выбранные сообщения") } .padding(.horizontal, 16) .padding(.vertical, 10) .background(.ultraThinMaterial) } private var composer: some View { VStack(spacing: 8) { if let editingMessage { editingBanner(editingMessage) } else if let replyingToMessage { replyBanner(replyingToMessage) } HStack(alignment: .bottom, spacing: 8) { Group { if isCapturePresentationVisible { recordingStatusStrip } else { HStack(alignment: .bottom, spacing: 8) { attachmentButton .disabled(!canOpenAttachments) messageInput } } } .frame(maxWidth: .infinity) composerActionControl } .padding(.horizontal, 10) .padding(.bottom, 8) } .padding(.top, 6) .background(Color.clear) .animation(.easeInOut(duration: 0.18), value: isCapturePresentationVisible) } private var messageInput: some View { HStack(alignment: .bottom, spacing: 8) { TextField( editingMessageID == nil ? "Сообщение" : "Изменить сообщение", text: $draft, axis: .vertical ) .focused($isComposerFocused) .lineLimit(1...6) .textFieldStyle(.plain) .submitLabel(.send) .onSubmit { sendDraft() } Button { emojiPickerPresentation = .composer } label: { Image(systemName: "face.smiling") .font(.system(size: 20, weight: .regular)) .foregroundStyle(.secondary) .padding(.bottom, 1) } .buttonStyle(.plain) .accessibilityLabel("Эмодзи") } .padding(.leading, 14) .padding(.trailing, 12) .padding(.vertical, 9) .frame(minHeight: 42) .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 21, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 21, style: .continuous) .stroke( LinearGradient( colors: [Color.white.opacity(0.3), Color.secondary.opacity(0.18)], startPoint: .top, endPoint: .bottom ), lineWidth: 0.75 ) ) .shadow(color: .black.opacity(0.075), radius: 8, y: 2) .contentShape(RoundedRectangle(cornerRadius: 21, style: .continuous)) } @ViewBuilder private var composerActionControl: some View { if editingMessageID != nil || !draftIsEmpty { Button(action: sendDraft) { Image(systemName: "arrow.up") .font(.system(size: 18, weight: .bold)) .foregroundStyle(canUseComposer ? Color.accentColor : Color.secondary) .frame(width: 42, height: 42) .background(.ultraThinMaterial, in: Circle()) .overlay( Circle() .stroke(Color.secondary.opacity(0.2), lineWidth: 0.75) ) .shadow(color: .black.opacity(0.075), radius: 8, y: 2) } .buttonStyle(.plain) .disabled(!canUseComposer) .accessibilityLabel(editingMessageID == nil ? "Отправить" : "Сохранить исправление") } else { TelegramRecordButton( mode: .voice, isRecording: isCapturePresentationVisible, isLocked: captureIsLocked, isFinalizing: false, isEnabled: canUseComposer, onTap: captureButtonTapped, onLongPressBegan: beginCaptureGesture, onLongPressChanged: updateCaptureGesture, onLongPressEnded: endCaptureGesture, onLongPressCancelled: { cancelComposerRecording(feedback: false) } ) .overlay(alignment: .top) { if isCapturePresentationVisible, !captureIsLocked { recordingLockIndicator .offset(y: -74 + min(12, max(-14, captureDragTranslation.height * 0.12))) .transition(.scale.combined(with: .opacity)) } } } } private var attachmentButton: some View { Menu { #if os(iOS) Button { presentCamera() } label: { Label("Камера", systemImage: "camera.fill") } .disabled(!SystemPhotoCameraView.isAvailable) #endif Button { dismissKeyboard() showingMediaPicker = true } label: { Label("Фото или видео (несколько)", systemImage: "photo.on.rectangle") } Button { presentFileImporter(.audio) } label: { Label("Музыка", systemImage: "music.note") } Button { presentVideoNoteRecorder() } label: { Label("Видеосообщение", systemImage: "video.circle") } Button { dismissKeyboard() model.audioPlayback.stop() showingLocationPicker = true } label: { Label("Отправить геопозицию", systemImage: "location.fill") } Divider() Button { presentFileImporter(.files) } label: { Label("Файлы (несколько)", systemImage: "doc.on.doc") } } label: { ZStack { Circle() .fill(.ultraThinMaterial) .overlay( Circle() .stroke(Color.secondary.opacity(0.18), lineWidth: 0.75) ) if model.isSendingAttachment || isPreparingAttachments { ProgressView() .controlSize(.small) } else { Image(systemName: "paperclip") .font(.system(size: 21, weight: .semibold)) .foregroundStyle(.primary) } } .frame(width: 42, height: 42) .shadow(color: .black.opacity(0.075), radius: 8, y: 2) .contentShape(Circle()) } .buttonStyle(.plain) .accessibilityLabel("Добавить вложение") .help("Открыть камеру или добавить фото, видео, файлы, музыку и геопозицию") } private var editingMessage: ChatMessage? { guard let editingMessageID else { return nil } return model.selectedMessages.first { $0.clientID == editingMessageID } } private var replyingToMessage: ChatMessage? { guard let replyingToMessageID else { return nil } return model.selectedMessages.first { $0.clientID == replyingToMessageID && $0.canBeRepliedTo } } private var requiresOMEMO: Bool { editingMessage?.security == .omemo || (editingMessageID == nil && encryptionEnabled) } private var encryptionEnabled: Bool { model.encryptionEnabled(for: conversation.jid) } private var encryptionIcon: String { if !encryptionEnabled { return "lock.open" } return model.isOMEMOReady ? "lock.fill" : "lock.trianglebadge.exclamationmark" } private var liveConversation: Conversation { model.conversations.first(where: { $0.jid == conversation.jid }) ?? conversation } private var chatNavigationTitle: some View { VStack(spacing: 0) { Text(liveConversation.displayName) .font(.headline) .lineLimit(1) if let typingText = model.typingText(for: liveConversation) { Text(typingText) .font(.caption2.weight(.medium)) .foregroundStyle(.tint) .lineLimit(1) .transition(.opacity) } } .animation(.easeInOut(duration: 0.16), value: model.typingText(for: liveConversation)) .accessibilityElement(children: .combine) .padding() } private var canSendToConversation: Bool { !conversation.isGroup || liveConversation.isGroupJoined } private var emptyChatTitle: String { if conversation.isGroup { return "Групповая комната" } return encryptionEnabled ? "Новый защищённый чат" : "Новый чат без шифрования" } private var emptyChatDescription: String { if conversation.isGroup { return liveConversation.isGroupJoined ? "Напишите первое сообщение участникам комнаты." : "Войдите в комнату, чтобы отправлять сообщения." } return encryptionEnabled ? "Сообщения этому контакту будут отправляться с OMEMO." : "Сообщения этому контакту будут передаваться открытым текстом внутри TLS-соединения." } private var groupJoinBanner: some View { HStack(spacing: 10) { Image(systemName: liveConversation.invitedBy == nil ? "person.3" : "envelope.badge") .foregroundStyle(.tint) VStack(alignment: .leading, spacing: 2) { Text( liveConversation.invitedBy == nil ? "Комната не подключена" : "Вас пригласили в комнату" ) .font(.subheadline.weight(.semibold)) Text(liveConversation.invitedBy ?? conversation.jid) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } Spacer() Button(liveConversation.invitedBy == nil ? "Войти" : "Принять") { Task { await model.joinGroup(jid: conversation.jid) } } .buttonStyle(.borderedProminent) .controlSize(.small) } .padding(.horizontal, 12) .padding(.vertical, 9) .background(Color.accentColor.opacity(0.09)) } private var draftIsEmpty: Bool { draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } private var canUseComposer: Bool { canSendToConversation && (!requiresOMEMO || model.isOMEMOReady) && !model.isSendingAttachment && !isPreparingAttachments && (editingMessageID == nil || !draftIsEmpty) } private var canOpenAttachments: Bool { canUseComposer && !isCapturePresentationVisible && editingMessageID == nil } private var isCapturePresentationVisible: Bool { activeCaptureMode != nil || preparingCaptureMode != nil || audioRecorder.isRecording } private var captureElapsed: TimeInterval { audioRecorder.elapsed } private var recordingCancelHintOpacity: Double { ComposerRecordingGesturePolicy.cancelHintOpacity(for: captureDragTranslation) } private var recordingStatusStrip: some View { HStack(spacing: 9) { Circle() .fill(Color.red) .frame(width: 9, height: 9) Text(formatDuration(captureElapsed)) .font(.system(.body, design: .monospaced).weight(.semibold)) .contentTransition(.numericText()) Spacer(minLength: 6) if captureIsLocked { Button("Отмена") { cancelComposerRecording() } .buttonStyle(.plain) .font(.subheadline.weight(.medium)) .foregroundStyle(.red) } else { Label("Влево — отмена", systemImage: "chevron.left") .labelStyle(.titleAndIcon) .font(.subheadline) .foregroundStyle(.secondary) .lineLimit(1) .offset(x: min(0, captureDragTranslation.width * 0.18)) .opacity(recordingCancelHintOpacity) } } .padding(.horizontal, 13) .frame(minHeight: 42) .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 21, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 21, style: .continuous) .stroke(Color.secondary.opacity(0.2), lineWidth: 0.75) ) } private var recordingLockIndicator: some View { VStack(spacing: 1) { Image(systemName: "lock.open.fill") .font(.system(size: 18, weight: .medium)) Image(systemName: "chevron.up") .font(.system(size: 10, weight: .bold)) } .foregroundStyle(.primary) .frame(width: 38, height: 55) .background(.ultraThinMaterial, in: Capsule()) .overlay(Capsule().stroke(Color.secondary.opacity(0.22), lineWidth: 0.75)) .accessibilityLabel("Проведите вверх, чтобы зафиксировать запись") } private func captureButtonTapped() { if captureIsLocked { finishComposerRecording() } } private func beginCaptureGesture() { guard canUseComposer, draftIsEmpty, editingMessageID == nil, !isCapturePresentationVisible else { return } dismissKeyboard() model.audioPlayback.stop() captureStartTask?.cancel() let attemptID = UUID() captureAttemptID = attemptID captureGestureIsActive = true captureIsLocked = false captureDragTranslation = .zero preparingCaptureMode = .voice activeCaptureMode = nil impactFeedback() captureStartTask = Task { @MainActor in do { try await audioRecorder.start() guard !Task.isCancelled, captureAttemptID == attemptID, captureGestureIsActive || captureIsLocked else { audioRecorder.cancel() return } guard captureAttemptID == attemptID else { return } preparingCaptureMode = nil activeCaptureMode = .voice } catch { audioRecorder.cancel() guard captureAttemptID == attemptID else { return } resetCaptureInterface() if !(error is CancellationError) { model.errorMessage = error.localizedDescription } } } } private func updateCaptureGesture(_ translation: CGSize) { guard isCapturePresentationVisible, !captureIsLocked else { return } captureDragTranslation = translation switch ComposerRecordingGesturePolicy.resolution(for: translation) { case .continueRecording: break case .cancel: cancelComposerRecording() case .lock: captureIsLocked = true captureGestureIsActive = false captureDragTranslation = .zero impactFeedback() } } private func endCaptureGesture(_ translation: CGSize) { guard isCapturePresentationVisible else { return } updateCaptureGesture(translation) guard isCapturePresentationVisible, !captureIsLocked else { return } captureGestureIsActive = false guard activeCaptureMode != nil else { // A first-run permission prompt can outlive the physical press. // Do not send a surprise recording after the user releases it. cancelComposerRecording(feedback: false) return } finishComposerRecording() } private func finishComposerRecording() { captureGestureIsActive = false captureDragTranslation = .zero switch activeCaptureMode { case .voice: do { let recording = try audioRecorder.finish() resetCaptureInterface() Task { let sentID = await model.sendAttachment( from: recording.url, preferredKind: .voice, duration: recording.duration, to: conversation.jid ) if sentID != nil { try? FileManager.default.removeItem(at: recording.url) } } } catch { resetCaptureInterface() model.errorMessage = error.localizedDescription } case nil: cancelComposerRecording(feedback: false) } } private func cancelComposerRecording(feedback: Bool = true) { let hadCapture = isCapturePresentationVisible captureStartTask?.cancel() captureStartTask = nil captureAttemptID = UUID() if audioRecorder.isRecording { audioRecorder.cancel() } resetCaptureInterface() if feedback, hadCapture { impactFeedback() } } private func resetCaptureInterface() { captureStartTask?.cancel() captureStartTask = nil captureGestureIsActive = false captureIsLocked = false captureDragTranslation = .zero preparingCaptureMode = nil activeCaptureMode = nil } private func releaseArchiveSyncAfterCapture() { guard captureSuspendsArchiveSync else { return } captureSuspendsArchiveSync = false model.setVideoNoteCaptureActive(false) } private func dismissKeyboard() { isComposerFocused = false #if os(iOS) UIApplication.shared.sendAction( #selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil ) #endif } private func impactFeedback() { #if os(iOS) UIImpactFeedbackGenerator(style: .medium).impactOccurred() #endif } private func sendDraft() { let outgoing = draft guard canSendToConversation, !outgoing.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } draft = "" if let editingMessageID { self.editingMessageID = nil Task { await model.editMessage(id: editingMessageID, newBody: outgoing) } } else { let replyTarget = replyingToMessage replyingToMessageID = nil Task { await model.sendText( outgoing, to: conversation.jid, replyingTo: replyTarget ) } } } private func stageImportedFiles( _ urls: [URL], preferredKind: ChatMessage.Kind? = nil ) async { guard !urls.isEmpty else { return } isPreparingAttachments = true defer { isPreparingAttachments = false } let prepared = await model.prepareAttachmentDrafts( from: urls, preferredKind: preferredKind, for: conversation.jid ) guard !prepared.isEmpty else { return } model.discardAttachmentDrafts(attachmentDrafts) attachmentDrafts = prepared presentAttachmentPreviewAfterPickerDismissal() } private func stageCapturedMedia(_ media: CapturedCameraMedia) async { defer { try? FileManager.default.removeItem(at: media.url) photoCameraIsPreparingResult = false releaseArchiveSyncAfterCapture() } await stageImportedFiles([media.url], preferredKind: media.kind) } private func presentCamera() { #if os(iOS) guard SystemPhotoCameraView.isAvailable else { model.errorMessage = "Камера недоступна на этом устройстве." return } dismissKeyboard() model.audioPlayback.stop() photoCameraIsPreparingResult = false if !captureSuspendsArchiveSync { captureSuspendsArchiveSync = true model.setVideoNoteCaptureActive(true) } Task { @MainActor in await Task.yield() showingPhotoCamera = true } #endif } private func presentVideoNoteRecorder() { guard !showingVideoNoteRecorder, !videoNoteIsSending else { return } dismissKeyboard() model.audioPlayback.stop() if !captureSuspendsArchiveSync { captureSuspendsArchiveSync = true model.setVideoNoteCaptureActive(true) } Task { @MainActor in // Let the attachment menu finish dismissing before presenting the // dedicated camera. This keeps the menu tap separate from the // voice-message touch surface. await Task.yield() showingVideoNoteRecorder = true } } private func sendRecordedVideoNote(_ recording: VideoNoteRecorder.Recording) { videoNoteIsSending = true Task { @MainActor in defer { videoNoteIsSending = false releaseArchiveSyncAfterCapture() } let sentID = await model.sendAttachment( from: recording.url, preferredKind: .videoNote, duration: recording.duration, to: conversation.jid ) if sentID != nil { try? FileManager.default.removeItem(at: recording.url) } } } private func presentFileImporter(_ mode: FileImportMode) { dismissKeyboard() fileImportMode = mode // Let Menu finish dismissing before SwiftUI presents the native file // panel. A single importer owns both modes, so presentations no longer // compete with each other. Task { @MainActor in await Task.yield() showingFileImporter = true } } private func presentReplyThread(rootID: String, selectedReplyID: String) { guard model.message(withID: rootID, in: conversation.jid) != nil else { model.errorMessage = "Исходное сообщение ещё не загружено в локальную историю." return } withAnimation(.easeOut(duration: 0.2)) { replyThreadSelection = ReplyThreadSelection( rootID: rootID, selectedReplyID: selectedReplyID ) } } private func dismissReplyThread() { withAnimation(.easeIn(duration: 0.18)) { replyThreadSelection = nil } } private func replyThreadReplies( for root: ChatMessage, selectedReplyID: String ) -> [ChatMessage] { var sourceIdentifiers: Set = [root.clientID] if let stanzaID = root.stanzaID { sourceIdentifiers.insert(stanzaID) } if let replyIdentifier = root.replyIdentifier { sourceIdentifiers.insert(replyIdentifier) } var replies = model.selectedMessages.filter { candidate in guard let replyToID = candidate.replyToID else { return false } return sourceIdentifiers.contains(replyToID) } if !replies.contains(where: { $0.clientID == selectedReplyID }), let selected = model.message(withID: selectedReplyID, in: conversation.jid) { replies.append(selected) } return replies.sorted { lhs, rhs in if lhs.timestamp == rhs.timestamp { return lhs.clientID < rhs.clientID } return lhs.timestamp < rhs.timestamp } } private func stagePickedMedia(_ items: [PhotosPickerItem]) async { guard !items.isEmpty, !isPreparingAttachments else { return } isPreparingAttachments = true var sourceURLs: [URL] = [] var failedItemCount = 0 defer { pickedMediaItems = [] isPreparingAttachments = false sourceURLs.forEach(discardPickedMediaFile) } model.clearError() let prepared = await model.withMediaPreparationActivity { for item in items.prefix(20) { do { sourceURLs.append(try await loadPickedMediaFile(item)) } catch { failedItemCount += 1 } } guard !sourceURLs.isEmpty else { return [AttachmentDraft]() } return await model.prepareAttachmentDrafts( from: sourceURLs, for: conversation.jid ) } guard !sourceURLs.isEmpty else { model.errorMessage = MediaPickerError.cannotReadSelection.errorDescription return } failedItemCount += max(0, sourceURLs.count - prepared.count) guard !prepared.isEmpty else { return } model.discardAttachmentDrafts(attachmentDrafts) attachmentDrafts = prepared presentAttachmentPreviewAfterPickerDismissal() if failedItemCount > 0 { model.errorMessage = "Не удалось подготовить \(failedItemCount) из \(items.prefix(20).count) выбранных фото или видео. Остальные готовы к отправке." } } private func presentAttachmentPreviewAfterPickerDismissal() { guard !attachmentDrafts.isEmpty else { return } attachmentPreviewPresentationPending = true schedulePendingAttachmentPreview() } private func schedulePendingAttachmentPreview() { guard attachmentPreviewPresentationPending else { return } attachmentPreviewPresentationTask?.cancel() attachmentPreviewPresentationTask = Task { @MainActor in // `isPresented` becomes false at the beginning of UIKit's // dismissal animation. Starting another sheet in that same frame // is ignored on iOS. Leave a short hand-off window, then keep the // request pending until every system picker has really gone away. do { try await Task.sleep(nanoseconds: 350_000_000) } catch { return } for _ in 0..<120 { guard !Task.isCancelled, attachmentPreviewPresentationPending, !attachmentDrafts.isEmpty else { return } if !showingMediaPicker, !showingPhotoCamera, !showingFileImporter, !isPreparingAttachments, !showingAttachmentPreview { attachmentPreviewPresentationPending = false showingAttachmentPreview = true attachmentPreviewPresentationTask = nil return } do { try await Task.sleep(nanoseconds: 50_000_000) } catch { return } } attachmentPreviewPresentationTask = nil } } private func loadPickedMediaFile(_ item: PhotosPickerItem) async throws -> URL { let supportsImage = item.supportedContentTypes.contains { $0.conforms(to: .image) } let supportsVideo = item.supportedContentTypes.contains { $0.conforms(to: .movie) } // A Live Photo can advertise both representations. Prefer its still // image, while a video-only asset starts with the movie representation. let order = MediaPickerSelectionPolicy.preferredOrder( supportsImage: supportsImage, supportsVideo: supportsVideo ) var lastError: Error? for representation in order { do { switch representation { case .photo: return try await loadPickedPhoto(item) case .video: return try await loadPickedVideo(item) } } catch { lastError = error } } throw lastError ?? MediaPickerError.cannotReadSelection } private func loadPickedPhoto(_ item: PhotosPickerItem) async throws -> URL { let fileRepresentation = try? await item.loadTransferable(type: PickedPhoto.self) if let photo = fileRepresentation { return photo.url } let dataRepresentation = try await item.loadTransferable(type: Data.self) guard let data = dataRepresentation, !data.isEmpty else { throw MediaPickerError.cannotReadSelection } return try await Task.detached(priority: .userInitiated) { try writePickedImageData(data) }.value } private func loadPickedVideo(_ item: PhotosPickerItem) async throws -> URL { guard let video = try await item.loadTransferable(type: PickedVideo.self) else { throw MediaPickerError.cannotReadSelection } return video.url } private func discardPickedMediaFile(_ url: URL) { let pickerRoot = FileManager.default.temporaryDirectory .appendingPathComponent("LumaPhotoPicker", isDirectory: true) .standardizedFileURL let parent = url.deletingLastPathComponent().standardizedFileURL if parent.deletingLastPathComponent() == pickerRoot { try? FileManager.default.removeItem(at: parent) } else { try? FileManager.default.removeItem(at: url) } } private func startEditing(_ message: ChatMessage) { guard message.canBeEdited else { return } cancelComposerRecording(feedback: false) replyingToMessageID = nil editingMessageID = message.clientID draft = message.body isComposerFocused = true } private func handleEmojiSelection( _ emoji: String, for presentation: EmojiPickerPresentation ) { switch presentation { case .reaction(let message): Task { await model.toggleReaction(emoji, on: message) } case .composer: draft.append(emoji) } } private func beginMessageSelection(with message: ChatMessage) { cancelComposerRecording(feedback: false) dismissKeyboard() if editingMessageID != nil { cancelEditing() } cancelReplying() selectedMessageIDs.insert(message.clientID) } private func toggleMessageSelection(_ message: ChatMessage) { if selectedMessageIDs.contains(message.clientID) { selectedMessageIDs.remove(message.clientID) } else { selectedMessageIDs.insert(message.clientID) } } private func clearMessageSelection() { selectedMessageIDs.removeAll() } private func presentForwarding(_ messages: [ChatMessage]) { let forwardable = messages.filter(\.canBeForwarded) guard !forwardable.isEmpty else { return } forwardingSelection = MessageForwardSelection(messages: forwardable) } private func startReplying(to message: ChatMessage) { guard message.canBeRepliedTo else { return } cancelComposerRecording(feedback: false) if editingMessageID != nil { editingMessageID = nil draft = "" } replyingToMessageID = message.clientID isComposerFocused = true } private func cancelEditing() { editingMessageID = nil draft = "" } private func cancelReplying() { replyingToMessageID = nil } private func editingBanner(_ message: ChatMessage) -> some View { HStack(spacing: 10) { Capsule() .fill(Color.accentColor) .frame(width: 3, height: 34) Image(systemName: "pencil") .foregroundStyle(.tint) VStack(alignment: .leading, spacing: 1) { Text("Редактирование") .font(.caption.weight(.semibold)) .foregroundStyle(.tint) Text(message.body) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } Spacer() Button(action: cancelEditing) { Image(systemName: "xmark.circle.fill") .foregroundStyle(.secondary) } .buttonStyle(.plain) .accessibilityLabel("Отменить редактирование") } .padding(.horizontal, 14) .padding(.top, 8) } private func replyBanner(_ message: ChatMessage) -> some View { HStack(spacing: 10) { Capsule() .fill(Color.accentColor) .frame(width: 3, height: 36) VStack(alignment: .leading, spacing: 1) { Text( "Ответ: \(message.senderDisplayName ?? model.displayName(for: message.senderJID))" ) .font(.caption.weight(.semibold)) .foregroundStyle(.tint) Text(message.quotePreview) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } Spacer() Button(action: cancelReplying) { Image(systemName: "xmark.circle.fill") .foregroundStyle(.secondary) } .buttonStyle(.plain) .accessibilityLabel("Отменить ответ") } .padding(.horizontal, 14) .padding(.top, 8) .padding(.bottom, 3) } private func perform(_ action: MessageDestructiveAction) { let ids = Set(action.messages.map(\.clientID)) if let editingMessageID, ids.contains(editingMessageID) { cancelEditing() } if let replyingToMessageID, ids.contains(replyingToMessageID) { cancelReplying() } selectedMessageIDs.subtract(ids) switch action.kind { case .localDelete: model.deleteMessagesLocally(ids: ids, in: conversation.jid) case .retract: Task { for message in action.messages where message.canBeRetracted { await model.retractMessage(id: message.clientID) } } } } private func datePill(_ date: Date) -> some View { Text(date, format: .dateTime.day().month(.wide).year()) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) .padding(.horizontal, 11) .padding(.vertical, 5) .background(.thinMaterial, in: Capsule()) .padding(.vertical, 10) } private func jumpToLatestButton(using proxy: ScrollViewProxy) -> some View { Button { scrollToBottom(proxy, animated: true) } label: { Image(systemName: "chevron.down") .font(.system(size: 20, weight: .semibold)) .foregroundStyle(.primary) .frame(width: 46, height: 46) .background(.ultraThinMaterial, in: Circle()) .overlay { Circle() .stroke(Color.accentColor.opacity(0.42), lineWidth: 1) } .shadow(color: .black.opacity(0.16), radius: 10, y: 4) } .buttonStyle(.plain) .contentShape(Circle()) .accessibilityLabel("Перейти к последнему сообщению") .help("Перейти в конец истории") } private func scrollToBottom(_ proxy: ScrollViewProxy, animated: Bool) { guard !model.selectedMessages.isEmpty else { return } let action = { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) } if animated { withAnimation(.easeOut(duration: 0.2)) { action() } } else { action() } } private func performInitialScrollAfterLayout(using proxy: ScrollViewProxy) async { guard !hasCompletedInitialScroll else { return } guard !model.selectedMessages.isEmpty else { // Empty conversation: there is nothing to scroll to, so the // scroll-to-top gesture can never fire. Load the first page of // history directly so a newly-created or emptied chat still fills. if model.hasMoreOlderHistory, !model.isLoadingOlderHistory { model.loadOlderHistoryForSelectedConversation() } return } // ScrollViewReader can receive its first command before a long // LazyVStack has published the bottom anchor. Yield once for layout, // then repeat on the next frame to make opening at the latest message // deterministic on iOS, iPadOS and macOS. await Task.yield() guard !Task.isCancelled else { return } scrollToBottom(proxy, animated: false) try? await Task.sleep(nanoseconds: 50_000_000) guard !Task.isCancelled else { return } scrollToBottom(proxy, animated: false) hasCompletedInitialScroll = true } private func updateTimelineBottomProximity( bottomY: CGFloat, viewportHeight: CGFloat ) { let isNearBottom = ChatScrollPositionPolicy.isNearBottom( bottomY: bottomY, viewportHeight: viewportHeight ) guard isNearBottom != isNearTimelineBottom else { return } isNearTimelineBottom = isNearBottom } private func formatDuration(_ duration: TimeInterval) -> String { let seconds = max(0, Int(duration.rounded(.down))) return String(format: "%d:%02d", seconds / 60, seconds % 60) } } private struct TimelineBottomYPreferenceKey: PreferenceKey { static var defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } } #if os(iOS) /// Reads the real UIScrollView offset without replacing its delegate or /// installing another pan recognizer. SwiftUI geometry preferences can stop /// updating after a programmatic ScrollViewReader jump on some iOS releases. private struct ChatScrollMetricsObserver: UIViewRepresentable { let identity: String let onNearBottomChange: (Bool) -> Void init(identity: String, onNearBottomChange: @escaping (Bool) -> Void) { self.identity = identity self.onNearBottomChange = onNearBottomChange } func makeUIView(context: Context) -> ObserverView { let view = ObserverView(frame: .zero) view.identity = identity view.onNearBottomChange = onNearBottomChange return view } func updateUIView(_ uiView: ObserverView, context: Context) { uiView.onNearBottomChange = onNearBottomChange if uiView.identity != identity { uiView.identity = identity uiView.resetReportedState() } uiView.attachToAncestorScrollViewIfNeeded() } static func dismantleUIView(_ uiView: ObserverView, coordinator: ()) { uiView.detach() } final class ObserverView: UIView { var identity = "" var onNearBottomChange: ((Bool) -> Void)? private weak var observedScrollView: UIScrollView? private var observations: [NSKeyValueObservation] = [] private var lastNearBottom: Bool? private var attachmentScheduled = false private var metricsReportScheduled = false override init(frame: CGRect) { super.init(frame: frame) isUserInteractionEnabled = false backgroundColor = .clear } required init?(coder: NSCoder) { nil } override func didMoveToWindow() { super.didMoveToWindow() attachToAncestorScrollViewIfNeeded() } override func didMoveToSuperview() { super.didMoveToSuperview() attachToAncestorScrollViewIfNeeded() } func attachToAncestorScrollViewIfNeeded() { guard window != nil, !attachmentScheduled else { return } attachmentScheduled = true DispatchQueue.main.async { [weak self] in guard let self else { return } self.attachmentScheduled = false var ancestor = self.superview while let view = ancestor, !(view is UIScrollView) { ancestor = view.superview } guard let scrollView = ancestor as? UIScrollView else { self.detach() return } self.observe(scrollView) } } func resetReportedState() { lastNearBottom = nil scheduleMetricsReport() } func detach() { observations.forEach { $0.invalidate() } observations.removeAll(keepingCapacity: false) observedScrollView = nil metricsReportScheduled = false } private func observe(_ scrollView: UIScrollView) { guard observedScrollView !== scrollView else { return } detach() observedScrollView = scrollView observations = [ scrollView.observe(\.contentOffset, options: [.initial, .new]) { [weak self] _, _ in self?.scheduleMetricsReport() }, scrollView.observe(\.contentSize, options: [.new]) { [weak self] _, _ in self?.scheduleMetricsReport() }, scrollView.observe(\.bounds, options: [.new]) { [weak self] _, _ in self?.scheduleMetricsReport() }, ] } private func scheduleMetricsReport() { guard !metricsReportScheduled else { return } metricsReportScheduled = true DispatchQueue.main.async { [weak self] in guard let self else { return } self.metricsReportScheduled = false self.reportMetricsNow() } } private func reportMetricsNow() { guard let scrollView = observedScrollView else { return } let maximumOffsetY = max( -scrollView.adjustedContentInset.top, scrollView.contentSize.height - scrollView.bounds.height + scrollView.adjustedContentInset.bottom ) let distance = maximumOffsetY - scrollView.contentOffset.y let isNearBottom = ChatScrollPositionPolicy.isNearBottom( distanceFromBottom: distance ) guard lastNearBottom != isNearBottom else { return } lastNearBottom = isNearBottom onNearBottomChange?(isNearBottom) } } } #endif enum ComposerRecordingGestureResolution: Equatable { case continueRecording case cancel case lock } struct ComposerRecordingGesturePolicy { static let cancelDistance: CGFloat = 92 static let lockDistance: CGFloat = 78 static let cancelHintFadeDistance: CGFloat = 150 static let maximumCancelHintFade: CGFloat = 0.72 static let longPressDelayNanoseconds: UInt64 = 240_000_000 static let minimumLongPressDuration: TimeInterval = 0.22 static func resolution(for translation: CGSize) -> ComposerRecordingGestureResolution { let cancelProgress = max(0, -translation.width) / cancelDistance let lockProgress = max(0, -translation.height) / lockDistance if cancelProgress >= 1, cancelProgress >= lockProgress { return .cancel } if lockProgress >= 1 { return .lock } return .continueRecording } static func cancelHintOpacity(for translation: CGSize) -> Double { let leftwardDistance = max(CGFloat.zero, -translation.width) let fadeAmount = min(maximumCancelHintFade, leftwardDistance / cancelHintFadeDistance) return Double(CGFloat(1) - fadeAmount) } static func isLongPress(elapsed: TimeInterval) -> Bool { elapsed >= minimumLongPressDuration } } private enum ComposerCaptureMode: Equatable { case voice var systemImage: String { "mic.fill" } var recordingAccessibilityLabel: String { "Запись голосового сообщения" } var idleAccessibilityLabel: String { "Удерживайте для голосового сообщения" } } @MainActor private struct TelegramRecordButton: View { let mode: ComposerCaptureMode let isRecording: Bool let isLocked: Bool let isFinalizing: Bool let isEnabled: Bool let onTap: () -> Void let onLongPressBegan: () -> Void let onLongPressChanged: (CGSize) -> Void let onLongPressEnded: (CGSize) -> Void let onLongPressCancelled: () -> Void @State private var isPressing = false @State private var didBeginLongPress = false @State private var pressStartedAt: Date? @State private var longPressTask: Task? var body: some View { ZStack { Circle() .fill(.ultraThinMaterial) .overlay { Circle() .fill(isRecording ? Color.red.opacity(0.13) : Color.clear) } .overlay( Circle() .stroke( isRecording ? Color.red.opacity(0.42) : Color.secondary.opacity(0.2), lineWidth: 0.75 ) ) if isFinalizing { ProgressView() .controlSize(.small) .tint(iconColor) } else { Image(systemName: isLocked ? "arrow.up" : mode.systemImage) .font(.system(size: isLocked ? 18 : 20, weight: .bold)) .foregroundStyle(iconColor) .contentTransition(.symbolEffect(.replace)) } } .frame(width: 42, height: 42) .scaleEffect(isRecording && !isLocked ? 1.34 : (isPressing ? 1.08 : 1)) .shadow( color: isRecording ? Color.black.opacity(0.2) : Color.black.opacity(0.075), radius: 8, y: isRecording ? 4 : 2 ) .contentShape(Circle()) #if os(iOS) .overlay { ComposerRecordTouchSurface( onBegan: { beginPhysicalPress(at: $0) }, onMoved: movePhysicalPress, onEnded: { translation, eventTime in endPhysicalPress(translation, at: eventTime) }, onCancelled: cancelPhysicalPress ) .frame(maxWidth: .infinity, maxHeight: .infinity) .accessibilityHidden(true) } #else .gesture(recordGesture) #endif .opacity(isEnabled ? 1 : 0.45) .allowsHitTesting(isEnabled && !isFinalizing) .animation(.spring(response: 0.24, dampingFraction: 0.78), value: isRecording) .animation(.easeOut(duration: 0.12), value: isPressing) .accessibilityLabel(accessibilityLabel) .accessibilityHint("Во время записи проведите влево для отмены или вверх для фиксации") .onDisappear { longPressTask?.cancel() longPressTask = nil pressStartedAt = nil } } private var iconColor: Color { isRecording ? .red : .primary } private var accessibilityLabel: String { if isLocked { return "Отправить записанное сообщение" } return isRecording ? mode.recordingAccessibilityLabel : mode.idleAccessibilityLabel } private var recordGesture: some Gesture { DragGesture(minimumDistance: 0, coordinateSpace: .global) .onChanged { value in guard isEnabled, !isFinalizing else { return } if !isPressing { // DragGesture timestamps belong to the physical input // events. Date() here would measure when the main actor // finally handled the event and could turn a real hold // into a tap after a busy MAM batch. beginPhysicalPress(at: value.time) } if didBeginLongPress { movePhysicalPress(value.translation) } } .onEnded { value in endPhysicalPress(value.translation, at: value.time) } } private func beginPhysicalPress(at eventTime: Date) { guard isEnabled, !isFinalizing, !isPressing else { return } isPressing = true didBeginLongPress = false pressStartedAt = eventTime scheduleLongPress() } private func movePhysicalPress(_ translation: CGSize) { guard didBeginLongPress else { return } onLongPressChanged(translation) } private func endPhysicalPress(_ translation: CGSize, at eventTime: Date) { guard isPressing || didBeginLongPress else { return } longPressTask?.cancel() longPressTask = nil let wasLongPress = didBeginLongPress let elapsed = pressStartedAt.map { eventTime.timeIntervalSince($0) } ?? 0 isPressing = false didBeginLongPress = false pressStartedAt = nil if wasLongPress { onLongPressEnded(translation) } else if ComposerRecordingGesturePolicy.isLongPress(elapsed: elapsed) { // If MAM decryption delayed the main actor, the scheduled // recognizer callback may arrive after the finger lifts. Recover // the physical press duration instead of turning it into a tap. onLongPressBegan() onLongPressEnded(translation) } else { onTap() } } private func cancelPhysicalPress() { let wasLongPress = didBeginLongPress longPressTask?.cancel() longPressTask = nil isPressing = false didBeginLongPress = false pressStartedAt = nil if wasLongPress { // UIKit distinguishes a real touch-up from an interruption (for // example an app transition or system gesture). Never translate a // cancelled touch into the Telegram-style "release to send" path. onLongPressCancelled() } } private func scheduleLongPress() { longPressTask?.cancel() longPressTask = Task { @MainActor in do { try await Task.sleep( nanoseconds: ComposerRecordingGesturePolicy.longPressDelayNanoseconds ) } catch { return } guard isPressing else { return } didBeginLongPress = true onLongPressBegan() } } } #if os(iOS) /// A raw UIKit touch surface keeps ownership of the physical finger while the /// SwiftUI composer swaps its idle controls for the recording preview. A /// SwiftUI DragGesture can be cancelled by that hierarchy update and used to /// report the cancellation as an early end on some iOS releases. @MainActor private struct ComposerRecordTouchSurface: UIViewRepresentable { let onBegan: (Date) -> Void let onMoved: (CGSize) -> Void let onEnded: (CGSize, Date) -> Void let onCancelled: () -> Void func makeUIView(context: Context) -> TouchView { let view = TouchView(frame: .zero) update(view) return view } func updateUIView(_ uiView: TouchView, context: Context) { update(uiView) } private func update(_ view: TouchView) { view.onBegan = onBegan view.onMoved = onMoved view.onEnded = onEnded view.onCancelled = onCancelled } final class TouchView: UIView { var onBegan: ((Date) -> Void)? var onMoved: ((CGSize) -> Void)? var onEnded: ((CGSize, Date) -> Void)? var onCancelled: (() -> Void)? private var activeTouch: UITouch? private var origin = CGPoint.zero override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear isOpaque = false isExclusiveTouch = true isMultipleTouchEnabled = false isAccessibilityElement = false } required init?(coder: NSCoder) { nil } override func touchesBegan(_ touches: Set, with event: UIEvent?) { guard activeTouch == nil, let touch = touches.first else { return } activeTouch = touch origin = touch.location(in: window) onBegan?(eventDate(for: touch)) } override func touchesMoved(_ touches: Set, with event: UIEvent?) { guard let touch = trackedTouch(in: touches) else { return } onMoved?(translation(for: touch)) } override func touchesEnded(_ touches: Set, with event: UIEvent?) { guard let touch = trackedTouch(in: touches) else { return } let translation = translation(for: touch) let date = eventDate(for: touch) activeTouch = nil onEnded?(translation, date) } override func touchesCancelled(_ touches: Set, with event: UIEvent?) { guard trackedTouch(in: touches) != nil else { return } activeTouch = nil onCancelled?() } private func trackedTouch(in touches: Set) -> UITouch? { guard let activeTouch else { return nil } return touches.first(where: { $0 === activeTouch }) } private func translation(for touch: UITouch) -> CGSize { let location = touch.location(in: window) return CGSize( width: location.x - origin.x, height: location.y - origin.y ) } private func eventDate(for touch: UITouch) -> Date { Date().addingTimeInterval( touch.timestamp - ProcessInfo.processInfo.systemUptime ) } } } #endif private struct ReplyThreadSelection: Equatable { let rootID: String let selectedReplyID: String } private struct MessageForwardSelection: Identifiable { let id = UUID() let messages: [ChatMessage] } private enum FileImportMode { case files case audio var allowedContentTypes: [UTType] { switch self { case .files: return [.item] case .audio: return [.audio] } } var preferredKind: ChatMessage.Kind? { switch self { case .files: return nil case .audio: return .audio } } } private enum MediaPickerError: LocalizedError { case cannotReadSelection var errorDescription: String? { "Не удалось прочитать выбранное фото или видео. Попробуйте сохранить его в «Файлы» и отправить оттуда." } } private struct MessageDestructiveAction: Identifiable { enum Kind: Equatable { case localDelete case retract } let kind: Kind let messages: [ChatMessage] var id: String { let messageIDs = messages.map(\.clientID).sorted().joined(separator: "-") return "\(kind == .localDelete ? "local" : "retract")-\(messageIDs)" } var title: String { let plural = messages.count > 1 switch kind { case .localDelete: return plural ? "Удалить выбранные сообщения у себя?" : "Удалить сообщение у себя?" case .retract: return plural ? "Удалить выбранные сообщения у всех?" : "Удалить сообщение у всех?" } } var explanation: String { switch kind { case .localDelete: return messages.count > 1 ? "\(messageCountText) исчезнут только из истории Luma на этом устройстве." : "Сообщение исчезнет только из истории Luma на этом устройстве." case .retract: return "Luma отправит XMPP-запрос retract для \(messageCountText). Сервер и другие клиенты могут не удалить уже полученные копии." } } var confirmationTitle: String { kind == .localDelete ? "Удалить у меня" : "Удалить у всех" } private var messageCountText: String { let count = messages.count let remainder100 = count % 100 let remainder10 = count % 10 let word: String if (11...14).contains(remainder100) { word = "сообщений" } else if remainder10 == 1 { word = "сообщение" } else if (2...4).contains(remainder10) { word = "сообщения" } else { word = "сообщений" } return "\(count) \(word)" } } private struct PickedPhoto: Transferable { let url: URL static var transferRepresentation: some TransferRepresentation { FileRepresentation(contentType: .image) { photo in SentTransferredFile(photo.url) } importing: { received in PickedPhoto(url: try copyPickedMediaFile(received.file, fallbackExtension: "jpg")) } } } private struct PickedVideo: Transferable { let url: URL static var transferRepresentation: some TransferRepresentation { FileRepresentation(contentType: .movie) { video in SentTransferredFile(video.url) } importing: { received in PickedVideo(url: try copyPickedMediaFile(received.file, fallbackExtension: "mov")) } } } private func copyPickedMediaFile(_ source: URL, fallbackExtension: String) throws -> URL { let fileManager = FileManager.default let rootDirectory = fileManager.temporaryDirectory .appendingPathComponent("LumaPhotoPicker", isDirectory: true) let selectionDirectory = rootDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) try fileManager.createDirectory( at: selectionDirectory, withIntermediateDirectories: true ) let values = try? source.resourceValues(forKeys: [.nameKey, .contentTypeKey]) let fileExtension = source.pathExtension.isEmpty ? (values?.contentType?.preferredFilenameExtension ?? fallbackExtension) : source.pathExtension var filename = values?.name ?? source.lastPathComponent if filename.isEmpty { filename = "media.\(fileExtension)" } else if URL(fileURLWithPath: filename).pathExtension.isEmpty { filename += ".\(fileExtension)" } let forbidden = CharacterSet(charactersIn: "/\\:") .union(.controlCharacters) filename = filename.components(separatedBy: forbidden).joined(separator: "_") let destination = selectionDirectory.appendingPathComponent(filename) let accessed = source.startAccessingSecurityScopedResource() defer { if accessed { source.stopAccessingSecurityScopedResource() } } var coordinatedError: NSError? var copyResult: Result? NSFileCoordinator(filePresenter: nil).coordinate( readingItemAt: source, options: [], error: &coordinatedError ) { coordinatedURL in copyResult = Result { do { try fileManager.copyItem(at: coordinatedURL, to: destination) let stagedValues = try destination.resourceValues( forKeys: [.fileSizeKey, .isRegularFileKey] ) guard stagedValues.isRegularFile == true, fileManager.isReadableFile(atPath: destination.path) else { throw MediaFileIOError.unreadableFile(filename) } guard (stagedValues.fileSize ?? 0) > 0 else { throw MediaFileIOError.emptyFile(filename) } return destination } catch { try? fileManager.removeItem(at: selectionDirectory) throw error } } } if let copyResult { return try copyResult.get() } try? fileManager.removeItem(at: selectionDirectory) throw MediaFileIOError.coordinatedReadFailed( coordinatedError?.localizedDescription ?? "неизвестная ошибка" ) } private func writePickedImageData(_ data: Data) throws -> URL { guard !data.isEmpty else { throw MediaFileIOError.emptyFile("Фото") } let contentType: UTType? if let imageSource = CGImageSourceCreateWithData(data as CFData, nil), let typeIdentifier = CGImageSourceGetType(imageSource) { contentType = UTType(typeIdentifier as String) } else { contentType = nil } let fileExtension = contentType?.preferredFilenameExtension ?? "jpg" let rootDirectory = FileManager.default.temporaryDirectory .appendingPathComponent("LumaPhotoPicker", isDirectory: true) let selectionDirectory = rootDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory( at: selectionDirectory, withIntermediateDirectories: true ) let destination = selectionDirectory.appendingPathComponent( "photo.\(fileExtension)" ) do { try data.write(to: destination, options: [.atomic]) return destination } catch { try? FileManager.default.removeItem(at: selectionDirectory) throw error } }