2381 lines
88 KiB
Swift
2381 lines
88 KiB
Swift
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<Void, Never>?
|
|
@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<String> = []
|
|
@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<Void, Never>?
|
|
@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<String> = [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<Void, Never>?
|
|
|
|
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<UITouch>, 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<UITouch>, with event: UIEvent?) {
|
|
guard let touch = trackedTouch(in: touches) else { return }
|
|
onMoved?(translation(for: touch))
|
|
}
|
|
|
|
override func touchesEnded(_ touches: Set<UITouch>, 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<UITouch>, with event: UIEvent?) {
|
|
guard trackedTouch(in: touches) != nil else { return }
|
|
activeTouch = nil
|
|
onCancelled?()
|
|
}
|
|
|
|
private func trackedTouch(in touches: Set<UITouch>) -> 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<URL, Error>?
|
|
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
|
|
}
|
|
}
|