216 lines
7.6 KiB
Swift
216 lines
7.6 KiB
Swift
import SwiftUI
|
|
import PhotosUI
|
|
import UniformTypeIdentifiers
|
|
import UIKit
|
|
|
|
struct MessageInputView: View {
|
|
@Binding var text: String
|
|
let isSending: Bool
|
|
let onSend: () -> Void
|
|
var onImageSelected: ((Data) -> Void)?
|
|
var onFileSelected: ((Data, String, String) -> Void)? // data, filename, mimeType
|
|
var members: [ConversationMember] = []
|
|
|
|
@State private var isProcessing = false
|
|
@State private var showFilePicker = false
|
|
@State private var showPhotoPicker = false
|
|
@State private var showMentionPopup = false
|
|
@State private var mentionCandidates: [ConversationMember] = []
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// Mention autocomplete popup
|
|
if showMentionPopup && !mentionCandidates.isEmpty {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
ForEach(mentionCandidates) { member in
|
|
Button {
|
|
completeMention(member: member)
|
|
} label: {
|
|
Text("@\(member.username)")
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.buttonStyle(.plain)
|
|
Divider()
|
|
}
|
|
}
|
|
}
|
|
.frame(maxHeight: 150)
|
|
.background(.ultraThinMaterial)
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
.padding(.horizontal)
|
|
}
|
|
|
|
HStack(spacing: 8) {
|
|
// Attach button
|
|
Menu {
|
|
Button {
|
|
showPhotoPicker = true
|
|
} label: {
|
|
Label("Photo", systemImage: "photo")
|
|
}
|
|
Button {
|
|
showFilePicker = true
|
|
} label: {
|
|
Label("File", systemImage: "doc")
|
|
}
|
|
} label: {
|
|
if isProcessing {
|
|
ProgressView()
|
|
.scaleEffect(0.8)
|
|
} else {
|
|
Image(systemName: "plus.circle.fill")
|
|
.font(.title2)
|
|
.foregroundStyle(.blue)
|
|
}
|
|
}
|
|
.disabled(isProcessing || isSending)
|
|
|
|
// Text field
|
|
TextField("Message", text: $text, axis: .vertical)
|
|
.textFieldStyle(.roundedBorder)
|
|
.lineLimit(1...5)
|
|
.onSubmit {
|
|
if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
onSend()
|
|
}
|
|
}
|
|
|
|
// Send button
|
|
Button(action: onSend) {
|
|
if isSending {
|
|
ProgressView()
|
|
.scaleEffect(0.8)
|
|
} else {
|
|
Image(systemName: "arrow.up.circle.fill")
|
|
.font(.title2)
|
|
.foregroundStyle(.blue)
|
|
}
|
|
}
|
|
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending)
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 8)
|
|
.background(.ultraThinMaterial)
|
|
.sheet(isPresented: $showPhotoPicker) {
|
|
ImagePickerView { data in
|
|
isProcessing = true
|
|
onImageSelected?(data)
|
|
isProcessing = false
|
|
}
|
|
}
|
|
.sheet(isPresented: $showFilePicker) {
|
|
DocumentPickerView { url in
|
|
guard url.startAccessingSecurityScopedResource() else { return }
|
|
defer { url.stopAccessingSecurityScopedResource() }
|
|
guard let data = try? Data(contentsOf: url) else { return }
|
|
let filename = url.lastPathComponent
|
|
let mimeType = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType ?? "application/octet-stream"
|
|
onFileSelected?(data, filename, mimeType)
|
|
}
|
|
}
|
|
.onChange(of: text) {
|
|
updateMentionCandidates()
|
|
}
|
|
} // end VStack
|
|
}
|
|
|
|
private func updateMentionCandidates() {
|
|
// Look for @prefix at end of text
|
|
guard let atRange = text.range(of: "@\\w*$", options: .regularExpression) else {
|
|
showMentionPopup = false
|
|
mentionCandidates = []
|
|
return
|
|
}
|
|
let prefix = String(text[atRange]).dropFirst().lowercased() // remove @
|
|
mentionCandidates = members.filter { member in
|
|
prefix.isEmpty || member.username.lowercased().hasPrefix(prefix)
|
|
}
|
|
showMentionPopup = !mentionCandidates.isEmpty
|
|
}
|
|
|
|
private func completeMention(member: ConversationMember) {
|
|
if let atRange = text.range(of: "@\\w*$", options: .regularExpression) {
|
|
text.replaceSubrange(atRange, with: "@\(member.username) ")
|
|
}
|
|
showMentionPopup = false
|
|
mentionCandidates = []
|
|
}
|
|
}
|
|
|
|
// MARK: - Image Picker (UIKit PHPicker wrapper)
|
|
|
|
struct ImagePickerView: UIViewControllerRepresentable {
|
|
let onImagePicked: (Data) -> Void
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(onImagePicked: onImagePicked)
|
|
}
|
|
|
|
func makeUIViewController(context: Context) -> PHPickerViewController {
|
|
var config = PHPickerConfiguration()
|
|
config.filter = .images
|
|
config.selectionLimit = 1
|
|
let picker = PHPickerViewController(configuration: config)
|
|
picker.delegate = context.coordinator
|
|
return picker
|
|
}
|
|
|
|
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}
|
|
|
|
class Coordinator: NSObject, PHPickerViewControllerDelegate {
|
|
let onImagePicked: (Data) -> Void
|
|
|
|
init(onImagePicked: @escaping (Data) -> Void) {
|
|
self.onImagePicked = onImagePicked
|
|
}
|
|
|
|
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
|
picker.dismiss(animated: true)
|
|
guard let provider = results.first?.itemProvider,
|
|
provider.canLoadObject(ofClass: UIImage.self) else { return }
|
|
provider.loadObject(ofClass: UIImage.self) { [weak self] image, _ in
|
|
guard let uiImage = image as? UIImage,
|
|
let data = uiImage.jpegData(compressionQuality: 0.9) else { return }
|
|
DispatchQueue.main.async {
|
|
self?.onImagePicked(data)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Document Picker (UIKit wrapper)
|
|
|
|
struct DocumentPickerView: UIViewControllerRepresentable {
|
|
let onPick: (URL) -> Void
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(onPick: onPick)
|
|
}
|
|
|
|
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
|
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.item])
|
|
picker.delegate = context.coordinator
|
|
picker.allowsMultipleSelection = false
|
|
return picker
|
|
}
|
|
|
|
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
|
|
|
|
class Coordinator: NSObject, UIDocumentPickerDelegate {
|
|
let onPick: (URL) -> Void
|
|
|
|
init(onPick: @escaping (URL) -> Void) {
|
|
self.onPick = onPick
|
|
}
|
|
|
|
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
|
guard let url = urls.first else { return }
|
|
onPick(url)
|
|
}
|
|
}
|
|
}
|