ios_client

This commit is contained in:
Filip
2026-03-14 12:43:56 +01:00
parent 5fd80e6dd6
commit 214da18779
74 changed files with 13136 additions and 284 deletions

View File

@@ -0,0 +1,382 @@
import SwiftUI
struct ChatView: View {
@State private var conversation: Conversation
var appState: AppState
var conversationListVM: ConversationListVM?
init(conversation: Conversation, appState: AppState, conversationListVM: ConversationListVM? = nil) {
self._conversation = State(initialValue: conversation)
self.appState = appState
self.conversationListVM = conversationListVM
}
@State private var viewModel = ChatViewModel()
@State private var inputText = ""
@State private var replyTo: Message?
@State private var showGroupInfo = false
@State private var showDMInfo = false
@State private var showSearch = false
@State private var showDeleteConfirm = false
@State private var showError = false
@State private var memberListenerTask: Task<Void, Never>?
@State private var forwardingMessage: Message?
@State private var showForwardPicker = false
@State private var showPinnedMessages = false
@State private var scrollTarget: String?
@State private var showVerification = false
@State private var verificationStatus: String = "unverified"
private var currentUserId: String {
appState.currentUser?.id ?? ""
}
private var isPartnerOnline: Bool {
guard !conversation.isGroup,
let partnerId = conversation.dmPartnerId(currentUserId: currentUserId),
let listVM = conversationListVM else {
return false
}
return listVM.onlineUsers.contains(partnerId)
}
var body: some View {
VStack(spacing: 0) {
searchBar
messagesScrollView
replyPreview
inputView
}
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbarContent }
.alert("Delete Conversation?", isPresented: $showDeleteConfirm) {
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
Task { await appState.chatClient.deleteConversation(convId: conversation.id) }
}
} message: {
Text(conversation.isGroup
? "This will remove all members and delete the conversation."
: "This will remove you from the conversation.")
}
.alert("Error", isPresented: $showError) {
Button("OK", role: .cancel) {}
} message: {
Text(viewModel.errorMessage ?? "Unknown error")
}
.sheet(isPresented: $showGroupInfo) {
GroupInfoView(conversation: $conversation, appState: appState, conversationListVM: conversationListVM)
}
.sheet(isPresented: $showDMInfo) {
if let partnerId = conversation.dmPartnerId(currentUserId: currentUserId) {
ProfileView(appState: appState, isOwnProfile: false, userId: partnerId)
}
}
.sheet(isPresented: $showForwardPicker) {
if let msg = forwardingMessage {
ForwardPickerView(message: msg, appState: appState)
}
}
.sheet(isPresented: $showVerification) {
if let partnerId = conversation.dmPartnerId(currentUserId: currentUserId) {
NavigationStack {
SafetyNumberView(
peerUserId: partnerId,
peerUsername: conversation.displayName(currentUserId: currentUserId),
chatClient: appState.chatClient
)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Done") { showVerification = false }
}
}
}
}
}
.sheet(isPresented: $showPinnedMessages) {
PinnedMessagesView(
messages: viewModel.messages.filter { $0.pinnedAt != nil },
onScrollTo: { scrollTarget = $0 }
)
}
.task {
// Use already-loaded data from conversation list (avoid redundant list_conversations call)
if let updated = conversationListVM?.conversations.first(where: { $0.id == conversation.id }) {
conversation = updated
}
conversationListVM?.markConversationRead(convId: conversation.id)
await viewModel.loadMessages(convId: conversation.id, chatClient: appState.chatClient)
viewModel.startNotificationListener(convId: conversation.id, chatClient: appState.chatClient)
// Load verification status for DM partner
if !conversation.isGroup,
let partnerId = conversation.dmPartnerId(currentUserId: currentUserId) {
verificationStatus = await appState.chatClient.getVerificationStatus(userId: partnerId)
}
memberListenerTask = Task {
for await notification in await appState.chatClient.makeNotificationStream() {
switch notification {
case .memberAdded, .memberRemoved, .conversationRenamed:
let refreshed = await appState.chatClient.listConversations()
if let updated = refreshed.first(where: { $0.id == conversation.id }) {
await MainActor.run { conversation = updated }
}
default:
break
}
}
}
}
.onDisappear {
viewModel.stop()
memberListenerTask?.cancel()
memberListenerTask = nil
}
}
// MARK: - Search Bar
@ViewBuilder
private var searchBar: some View {
if showSearch {
SearchOverlayView(
query: $viewModel.searchQuery,
matchCount: viewModel.searchResults.count,
currentIndex: viewModel.currentSearchIndex,
onSearch: { viewModel.search(query: $0) },
onNext: { viewModel.nextSearchResult() },
onPrev: { viewModel.prevSearchResult() },
onClose: { showSearch = false; viewModel.search(query: "") }
)
}
}
// MARK: - Messages
private var messagesScrollView: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 8) {
if viewModel.messages.count >= 50 {
Button("Load older messages") {
Task {
await viewModel.loadOlderMessages(convId: conversation.id, chatClient: appState.chatClient)
}
}
.font(.caption)
.padding()
}
ForEach(viewModel.messages) { message in
messageBubble(for: message)
.id(message.id)
}
}
.padding(.horizontal)
.padding(.vertical, 8)
}
.onChange(of: viewModel.messages.count) {
if let lastId = viewModel.messages.last?.id {
withAnimation { proxy.scrollTo(lastId, anchor: .bottom) }
}
}
.onChange(of: scrollTarget) {
if let target = scrollTarget {
withAnimation { proxy.scrollTo(target, anchor: .center) }
scrollTarget = nil
}
}
}
}
private func messageBubble(for message: Message) -> some View {
let isCurrentSearch = viewModel.searchResults.indices.contains(viewModel.currentSearchIndex)
&& viewModel.searchResults[viewModel.currentSearchIndex] == message.id
return MessageBubbleView(
message: message,
isMine: message.isMine(currentUserId: currentUserId),
isGroup: conversation.isGroup,
isHighlighted: viewModel.searchResults.contains(message.id),
isCurrentSearchResult: isCurrentSearch,
chatClient: appState.chatClient,
currentUserId: currentUserId,
onReply: { replyTo = message },
onReact: { reaction in
Task {
await viewModel.reactToMessage(
messageId: message.id, convId: conversation.id,
reaction: reaction, currentUserId: currentUserId,
chatClient: appState.chatClient
)
}
},
onForward: {
forwardingMessage = message
showForwardPicker = true
},
onPin: { pin in
Task {
await viewModel.pinMessage(
messageId: message.id, convId: conversation.id,
pin: pin, chatClient: appState.chatClient
)
}
},
onDelete: {
Task {
await viewModel.deleteMessage(
messageId: message.id, convId: conversation.id,
chatClient: appState.chatClient
)
}
}
)
}
// MARK: - Reply Preview
@ViewBuilder
private var replyPreview: some View {
if let reply = replyTo {
HStack {
Rectangle()
.fill(.blue)
.frame(width: 3)
VStack(alignment: .leading) {
Text(reply.senderUsername)
.font(.caption.bold())
Text(reply.text ?? "")
.font(.caption)
.lineLimit(1)
}
Spacer()
Button(action: { replyTo = nil }) {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
}
.padding(.horizontal)
.padding(.vertical, 6)
.background(.ultraThinMaterial)
}
}
// MARK: - Input
private var inputView: some View {
MessageInputView(
text: $inputText,
isSending: viewModel.isSending,
onSend: {
Task {
let text = inputText
inputText = ""
let reply = replyTo?.id
replyTo = nil
await viewModel.sendMessage(
convId: conversation.id, text: text,
members: conversation.members,
chatClient: appState.chatClient, replyTo: reply
)
}
},
onImageSelected: { imageData in
Task {
viewModel.isSending = true
let (success, msg, sentMessage) = await appState.chatClient.sendImage(
convId: conversation.id, imageData: imageData,
members: conversation.members
)
viewModel.isSending = false
if success, let sentMessage {
if !viewModel.messages.contains(where: { $0.id == sentMessage.id }) {
viewModel.messages.append(sentMessage)
}
await viewModel.saveCache(convId: conversation.id, chatClient: appState.chatClient)
} else if !success {
viewModel.errorMessage = msg
showError = true
}
}
},
onFileSelected: { fileData, filename, mimeType in
Task {
viewModel.isSending = true
let (success, msg, sentMessage) = await appState.chatClient.sendFile(
convId: conversation.id, fileData: fileData,
filename: filename, mimeType: mimeType,
members: conversation.members
)
viewModel.isSending = false
if success, let sentMessage {
if !viewModel.messages.contains(where: { $0.id == sentMessage.id }) {
viewModel.messages.append(sentMessage)
}
await viewModel.saveCache(convId: conversation.id, chatClient: appState.chatClient)
} else if !success {
viewModel.errorMessage = msg
showError = true
}
}
},
members: conversation.members
)
}
// MARK: - Toolbar
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .principal) {
HStack(spacing: 8) {
CircularAvatarView(
name: conversation.displayName(currentUserId: currentUserId),
imageData: conversationListVM?.avatarCache[conversation.id],
size: 28,
isGroup: conversation.isGroup
)
Text(conversation.displayName(currentUserId: currentUserId))
.font(.headline)
if !conversation.isGroup && verificationStatus == "verified" {
Image(systemName: "checkmark.shield.fill")
.font(.caption)
.foregroundStyle(.green)
}
if isPartnerOnline {
Circle().fill(.green).frame(width: 8, height: 8)
}
}
}
ToolbarItem(placement: .topBarTrailing) {
HStack(spacing: 16) {
if !conversation.isGroup {
Button(action: { showVerification = true }) {
Image(systemName: verificationStatus == "verified" ? "checkmark.shield.fill" : "shield")
.foregroundStyle(verificationStatus == "verified" ? .green : .secondary)
}
}
Button(action: { showPinnedMessages = true }) {
Image(systemName: "pin")
}
Button(action: { showSearch.toggle() }) {
Image(systemName: "magnifyingglass")
}
if conversation.isGroup {
Button(action: { showGroupInfo = true }) {
Image(systemName: "info.circle")
}
} else {
Button(action: { showDMInfo = true }) {
Image(systemName: "info.circle")
}
}
if !conversation.isGroup || conversation.createdBy == currentUserId {
Button(action: { showDeleteConfirm = true }) {
Image(systemName: "trash").foregroundStyle(.red)
}
}
}
}
}
}

View File

@@ -0,0 +1,77 @@
import SwiftUI
struct ForwardPickerView: View {
let message: Message
let appState: AppState
@Environment(\.dismiss) private var dismiss
@State private var conversations: [Conversation] = []
@State private var isLoading = true
@State private var isSending = false
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView("Loading conversations...")
} else if conversations.isEmpty {
Text("No conversations available")
.foregroundStyle(.secondary)
} else {
List(conversations) { conv in
Button {
forwardTo(conv)
} label: {
HStack {
CircularAvatarView(
name: conv.displayName(currentUserId: appState.currentUser?.id ?? ""),
size: 36,
isGroup: conv.isGroup
)
Text(conv.displayName(currentUserId: appState.currentUser?.id ?? ""))
Spacer()
}
}
.disabled(isSending)
}
}
}
.navigationTitle("Forward to...")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
.task {
conversations = await appState.chatClient.listConversations()
isLoading = false
}
}
private func forwardTo(_ conv: Conversation) {
isSending = true
Task {
let forwardPayload: [String: Any] = [
"forwarded_from": [
"sender": message.senderUsername,
"conversation_id": message.conversationId,
"message_id": message.id,
] as [String: Any]
]
let (success, _, _) = await appState.chatClient.sendMessage(
convId: conv.id,
text: message.text ?? "",
members: conv.members,
extraPayload: forwardPayload
)
await MainActor.run {
isSending = false
if success {
dismiss()
}
}
}
}
}

View File

@@ -0,0 +1,113 @@
import SwiftUI
import UIKit
import Photos
struct ImageViewerView: View {
let imageData: Data
@State private var scale: CGFloat = 1.0
@State private var saved = false
@State private var saveError: String?
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
GeometryReader { geo in
if let uiImage = UIImage(data: imageData) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
.scaleEffect(scale)
.gesture(
MagnifyGesture()
.onChanged { value in
scale = value.magnification
}
.onEnded { _ in
withAnimation {
scale = max(1.0, min(scale, 5.0))
}
}
)
.onTapGesture(count: 2) {
withAnimation {
scale = scale > 1 ? 1 : 2
}
}
.frame(width: geo.size.width, height: geo.size.height)
}
}
.overlay(alignment: .bottom) {
if let error = saveError {
Text(error)
.font(.caption)
.foregroundStyle(.white)
.padding(8)
.background(Capsule().fill(.red.opacity(0.8)))
.padding(.bottom, 40)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button {
dismiss()
} label: {
Image(systemName: "xmark")
.foregroundStyle(.white)
}
}
ToolbarItem(placement: .topBarTrailing) {
HStack(spacing: 16) {
// Share
if let uiImage = UIImage(data: imageData) {
ShareLink(item: Image(uiImage: uiImage), preview: SharePreview("Image", image: Image(uiImage: uiImage))) {
Image(systemName: "square.and.arrow.up")
.foregroundStyle(.white)
}
}
// Save to Photos
Button {
saveToPhotos()
} label: {
Image(systemName: saved ? "checkmark.circle.fill" : "arrow.down.to.line")
.foregroundStyle(saved ? .green : .white)
}
}
}
}
.toolbarBackground(.hidden, for: .navigationBar)
.background(.black)
}
}
private func saveToPhotos() {
guard let uiImage = UIImage(data: imageData) else {
withAnimation { saveError = "Invalid image data" }
return
}
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
DispatchQueue.main.async {
switch status {
case .authorized, .limited:
PHPhotoLibrary.shared().performChanges {
PHAssetChangeRequest.creationRequestForAsset(from: uiImage)
} completionHandler: { success, error in
DispatchQueue.main.async {
if success {
withAnimation { saved = true; saveError = nil }
} else {
withAnimation { saveError = error?.localizedDescription ?? "Save failed" }
}
}
}
case .denied, .restricted:
withAnimation { saveError = "Photo library access denied. Check Settings." }
default:
withAnimation { saveError = "Photo library access required" }
}
}
}
}
}

View File

@@ -0,0 +1,558 @@
import SwiftUI
import UIKit
struct MessageBubbleView: View {
let message: Message
let isMine: Bool
var isGroup: Bool = false
var isHighlighted: Bool = false
var isCurrentSearchResult: Bool = false
var chatClient: ChatClient?
var currentUserId: String = ""
var onReply: (() -> Void)?
var onReact: ((String) -> Void)?
var onForward: (() -> Void)?
var onPin: ((Bool) -> Void)?
var onDelete: (() -> Void)?
@State private var fullImageData: Data?
@State private var showFullImage = false
@State private var isLoadingImage = false
@State private var isLoadingFile = false
@State private var downloadedFileURL: URL?
@State private var showShareSheet = false
@State private var imageError: String?
var body: some View {
HStack {
if isMine { Spacer(minLength: 60) }
VStack(alignment: isMine ? .trailing : .leading, spacing: 4) {
if !isMine && isGroup {
Text(message.senderUsername)
.font(.caption.bold())
.foregroundStyle(.secondary)
}
if message.isDeleted {
Text("Message deleted")
.font(.body.italic())
.foregroundStyle(.secondary)
.padding(12)
.background(Color(.systemGray6))
.clipShape(RoundedRectangle(cornerRadius: 16))
} else {
// Forwarded header
if let fwd = message.forwardedFrom {
HStack(spacing: 4) {
Rectangle().fill(.cyan).frame(width: 3)
VStack(alignment: .leading, spacing: 1) {
Text("Forwarded from").font(.caption2).foregroundStyle(.secondary)
Text(fwd.sender).font(.caption.bold()).foregroundStyle(.cyan)
}
}
.padding(.horizontal, 8).padding(.top, 4)
}
// Reply reference
if message.replyTo != nil {
HStack(spacing: 4) {
Rectangle()
.fill(.blue.opacity(0.5))
.frame(width: 2)
Text("Reply to message")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 8)
}
// Image thumbnail
if let imageInfo = message.image {
imageView(imageInfo: imageInfo)
}
// File card
if let file = message.file {
VStack(alignment: .leading, spacing: 4) {
HStack {
if isLoadingFile {
ProgressView()
.scaleEffect(0.8)
} else {
Image(systemName: fileIcon(for: file.filename))
}
Text(file.filename)
.lineLimit(1)
}
.font(.subheadline)
Text(formatFileSize(file.size))
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(12)
.background(Color(.systemGray5))
.clipShape(RoundedRectangle(cornerRadius: 12))
.onTapGesture {
downloadAndShareFile(file: file)
}
}
// Text content with link detection
if let text = message.text, !text.isEmpty {
LinkText(text: text, isMine: isMine)
.padding(12)
.background(
isMine ? Color.blue : Color(.systemGray5)
)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
// Timestamp + checkmarks + reactions all on one line
HStack(spacing: 4) {
if message.pinnedAt != nil {
Image(systemName: "pin.fill").font(.caption2).foregroundStyle(.orange)
}
Text(formatTime(message.createdAt)).font(.caption2).foregroundStyle(.secondary)
if isMine {
deliveryIndicator
}
if !message.reactions.isEmpty {
inlineReactionBadges
}
}
.frame(maxWidth: .infinity, alignment: isMine ? .trailing : .leading)
}
}
.padding(2)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(isCurrentSearchResult ? Color.orange.opacity(0.3) :
isHighlighted ? Color.yellow.opacity(0.2) : Color.clear)
)
.contextMenu {
if !message.isDeleted {
Button(action: { onReply?() }) {
Label("Reply", systemImage: "arrowshape.turn.up.left")
}
Menu {
ForEach(ReactionEmoji.allowed, id: \.self) { key in
Button("\(ReactionEmoji.display[key] ?? "") \(key)") { onReact?(key) }
}
} label: {
Label("React", systemImage: "face.smiling")
}
Button(action: {
UIPasteboard.general.string = message.text ?? ""
// Auto-clear clipboard after 30 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 30) {
if UIPasteboard.general.string == message.text {
UIPasteboard.general.string = ""
}
}
}) {
Label("Copy", systemImage: "doc.on.doc")
}
Button(action: { onForward?() }) {
Label("Forward", systemImage: "arrowshape.turn.up.right")
}
Button(action: { onPin?(message.pinnedAt == nil) }) {
Label(message.pinnedAt == nil ? "Pin" : "Unpin",
systemImage: message.pinnedAt == nil ? "pin" : "pin.slash")
}
if isMine {
Button(role: .destructive, action: { onDelete?() }) {
Label("Delete", systemImage: "trash")
}
}
}
}
if !isMine { Spacer(minLength: 60) }
}
.sheet(isPresented: $showFullImage) {
if let data = fullImageData {
ImageViewerView(imageData: data)
}
}
.sheet(isPresented: $showShareSheet, onDismiss: {
// Clean up decrypted temp file after sharing
if let fileURL = downloadedFileURL {
try? FileManager.default.removeItem(at: fileURL)
downloadedFileURL = nil
}
}) {
if let fileURL = downloadedFileURL {
ActivityViewController(activityItems: [fileURL])
}
}
}
// MARK: - Reaction Badges (inline used in timestamp row)
private var inlineReactionBadges: some View {
let grouped = Dictionary(grouping: message.reactions, by: \.reaction)
return HStack(spacing: 2) {
ForEach(grouped.sorted(by: { $0.key < $1.key }), id: \.key) { reaction, users in
Button {
onReact?(reaction)
} label: {
Text(ReactionEmoji.display[reaction] ?? reaction)
.font(.caption)
}
.buttonStyle(.plain)
}
}
}
// MARK: - Delivery Indicator (checkmarks)
@ViewBuilder
private var deliveryIndicator: some View {
let isRead = message.readBy.contains(where: { $0 != "__delivered__" && $0 != currentUserId })
let isDelivered = message.readBy.contains("__delivered__")
if isRead {
// Read: 2 green checkmarks
HStack(spacing: -4) {
Image(systemName: "checkmark").font(.system(size: 9, weight: .bold))
Image(systemName: "checkmark").font(.system(size: 9, weight: .bold))
}
.foregroundStyle(.green)
} else if isDelivered {
// Delivered: 2 gray checkmarks
HStack(spacing: -4) {
Image(systemName: "checkmark").font(.system(size: 9, weight: .bold))
Image(systemName: "checkmark").font(.system(size: 9, weight: .bold))
}
.foregroundStyle(.secondary)
} else {
// Sent: 1 gray checkmark
Image(systemName: "checkmark").font(.system(size: 9, weight: .bold))
.foregroundStyle(.secondary)
}
}
// MARK: - Image View
@ViewBuilder
private func imageView(imageInfo: ImageInfo) -> some View {
VStack(spacing: 4) {
if let thumbB64 = imageInfo.thumbnail,
let thumbData = Data(base64Encoded: thumbB64),
let uiImage = UIImage(data: thumbData) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 220, maxHeight: 220)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay {
if isLoadingImage {
RoundedRectangle(cornerRadius: 12)
.fill(.black.opacity(0.4))
ProgressView()
.tint(.white)
}
}
.onTapGesture {
downloadAndShowFullImage(imageInfo: imageInfo)
}
} else {
// No thumbnail available show placeholder
RoundedRectangle(cornerRadius: 12)
.fill(Color(.systemGray5))
.frame(width: 160, height: 120)
.overlay {
if isLoadingImage {
ProgressView()
} else {
VStack(spacing: 6) {
Image(systemName: "photo")
.font(.title2)
Text(imageInfo.filename)
.font(.caption)
.lineLimit(1)
}
.foregroundStyle(.secondary)
}
}
.onTapGesture {
downloadAndShowFullImage(imageInfo: imageInfo)
}
}
if let error = imageError {
Text(error)
.font(.caption2)
.foregroundStyle(.red)
.onTapGesture {
imageError = nil
downloadAndShowFullImage(imageInfo: imageInfo)
}
}
}
}
private func downloadAndShowFullImage(imageInfo: ImageInfo) {
guard !isLoadingImage, let client = chatClient else { return }
// If already downloaded, show immediately
if fullImageData != nil {
showFullImage = true
return
}
imageError = nil
isLoadingImage = true
Task {
guard let aesKey = try? ProtocolHandler.decodeBinary(imageInfo.aesKey),
let iv = try? ProtocolHandler.decodeBinary(imageInfo.iv) else {
await MainActor.run {
isLoadingImage = false
imageError = "Failed to decode image keys"
}
return
}
let data = await client.downloadFile(fileId: imageInfo.fileId, aesKey: aesKey, iv: iv, conversationId: message.conversationId)
await MainActor.run {
isLoadingImage = false
if let data = data {
fullImageData = data
showFullImage = true
} else {
imageError = "Download failed, tap to retry"
}
}
}
}
// MARK: - File Download
private func downloadAndShareFile(file: FileInfo) {
guard !isLoadingFile, let client = chatClient else { return }
// If already downloaded, show share sheet immediately
if downloadedFileURL != nil {
showShareSheet = true
return
}
isLoadingFile = true
Task {
guard let aesKey = try? ProtocolHandler.decodeBinary(file.aesKey),
let iv = try? ProtocolHandler.decodeBinary(file.iv) else {
await MainActor.run { isLoadingFile = false }
return
}
let data = await client.downloadFile(fileId: file.fileId, aesKey: aesKey, iv: iv, conversationId: message.conversationId)
await MainActor.run {
isLoadingFile = false
if let data = data {
// Save to temp with file protection, clean up on dismiss
let tempDir = FileManager.default.temporaryDirectory
let fileURL = tempDir.appendingPathComponent(file.filename)
try? data.write(to: fileURL, options: .completeFileProtection)
downloadedFileURL = fileURL
showShareSheet = true
}
}
}
}
private func fileIcon(for filename: String) -> String {
let ext = (filename as NSString).pathExtension.lowercased()
switch ext {
case "pdf": return "doc.richtext"
case "doc", "docx": return "doc.text"
case "xls", "xlsx": return "tablecells"
case "ppt", "pptx": return "rectangle.on.rectangle"
case "zip", "rar", "7z": return "doc.zipper"
case "mp3", "wav", "m4a": return "music.note"
case "mp4", "mov", "avi": return "film"
case "txt": return "doc.plaintext"
default: return "paperclip"
}
}
// MARK: - Helpers
private func formatTime(_ date: Date) -> String {
let formatter = DateFormatter()
if Calendar.current.isDateInToday(date) {
formatter.dateFormat = "HH:mm"
} else {
formatter.dateFormat = "MMM d, HH:mm"
}
return formatter.string(from: date)
}
private func formatFileSize(_ bytes: Int) -> String {
if bytes < 1024 { return "\(bytes) B" }
if bytes < 1024 * 1024 { return "\(bytes / 1024) KB" }
return String(format: "%.1f MB", Double(bytes) / (1024 * 1024))
}
}
// MARK: - Link Text
struct LinkText: View {
let text: String
let isMine: Bool
var body: some View {
Text(buildAttributedString())
.environment(\.openURL, OpenURLAction { url in
UIApplication.shared.open(url)
return .handled
})
}
private func buildAttributedString() -> AttributedString {
var result = AttributedString()
guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else {
return appendPlainWithMentions(text[text.startIndex..<text.endIndex], to: &result)
}
let nsRange = NSRange(text.startIndex..., in: text)
let matches = detector.matches(in: text, range: nsRange)
if matches.isEmpty {
return appendPlainWithMentions(text[text.startIndex..<text.endIndex], to: &result)
}
var lastEnd = text.startIndex
for match in matches {
guard let matchRange = Range(match.range, in: text),
let url = match.url else { continue }
// Plain text before link (with mention highlighting)
if lastEnd < matchRange.lowerBound {
appendPlainWithMentions(text[lastEnd..<matchRange.lowerBound], to: &result)
}
// Link
let isSecure = url.scheme?.lowercased() == "https"
var link = AttributedString(text[matchRange])
link.link = url
link.underlineStyle = .single
if isMine {
link.foregroundColor = isSecure ? .cyan : .red
} else {
link.foregroundColor = isSecure ? .blue : .red
}
result.append(link)
lastEnd = matchRange.upperBound
}
// Remaining text
if lastEnd < text.endIndex {
appendPlainWithMentions(text[lastEnd..<text.endIndex], to: &result)
}
return result
}
private static let mentionRegex = try! NSRegularExpression(pattern: "@(\\w+)", options: [])
@discardableResult
private func appendPlainWithMentions(_ substring: Substring, to result: inout AttributedString) -> AttributedString {
let str = String(substring)
let nsRange = NSRange(str.startIndex..., in: str)
let matches = Self.mentionRegex.matches(in: str, range: nsRange)
if matches.isEmpty {
var plain = AttributedString(str)
plain.foregroundColor = isMine ? .white : .primary
result.append(plain)
return result
}
let mentionColor = Color(red: 0.537, green: 0.706, blue: 0.980)
var lastEnd = str.startIndex
for match in matches {
guard let matchRange = Range(match.range, in: str) else { continue }
if lastEnd < matchRange.lowerBound {
var plain = AttributedString(str[lastEnd..<matchRange.lowerBound])
plain.foregroundColor = isMine ? .white : .primary
result.append(plain)
}
var mention = AttributedString(str[matchRange])
mention.foregroundColor = mentionColor
mention.font = .body.bold()
result.append(mention)
lastEnd = matchRange.upperBound
}
if lastEnd < str.endIndex {
var plain = AttributedString(str[lastEnd..<str.endIndex])
plain.foregroundColor = isMine ? .white : .primary
result.append(plain)
}
return result
}
}
// MARK: - Flow Layout
struct FlowLayout: Layout {
var spacing: CGFloat = 4
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let maxWidth = proposal.width ?? .infinity
var x: CGFloat = 0
var y: CGFloat = 0
var rowHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x + size.width > maxWidth && x > 0 {
x = 0
y += rowHeight + spacing
rowHeight = 0
}
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
return CGSize(width: maxWidth, height: y + rowHeight)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
var x = bounds.minX
var y = bounds.minY
var rowHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x + size.width > bounds.maxX && x > bounds.minX {
x = bounds.minX
y += rowHeight + spacing
rowHeight = 0
}
subview.place(at: CGPoint(x: x, y: y), proposal: .unspecified)
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
}
}
// MARK: - Share Sheet
struct ActivityViewController: UIViewControllerRepresentable {
let activityItems: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}

View File

@@ -0,0 +1,215 @@
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)
}
}
}

View File

@@ -0,0 +1,62 @@
import SwiftUI
struct PinnedMessagesView: View {
let messages: [Message]
var onScrollTo: ((String) -> Void)?
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Group {
if messages.isEmpty {
Text("No pinned messages")
.foregroundStyle(.secondary)
} else {
List(messages) { message in
Button {
dismiss()
onScrollTo?(message.id)
} label: {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "pin.fill")
.font(.caption)
.foregroundStyle(.orange)
Text(message.senderUsername)
.font(.caption.bold())
Spacer()
Text(formatTime(message.createdAt))
.font(.caption2)
.foregroundStyle(.secondary)
}
Text(message.text ?? "")
.font(.body)
.lineLimit(3)
}
.padding(.vertical, 4)
}
.buttonStyle(.plain)
}
}
}
.navigationTitle("Pinned Messages")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
}
}
}
}
private func formatTime(_ date: Date) -> String {
let formatter = DateFormatter()
if Calendar.current.isDateInToday(date) {
formatter.dateFormat = "HH:mm"
} else {
formatter.dateFormat = "MMM d, HH:mm"
}
return formatter.string(from: date)
}
}

View File

@@ -0,0 +1,46 @@
import SwiftUI
struct SearchOverlayView: View {
@Binding var query: String
let matchCount: Int
let currentIndex: Int
let onSearch: (String) -> Void
let onNext: () -> Void
let onPrev: () -> Void
let onClose: () -> Void
var body: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search messages", text: $query)
.textFieldStyle(.roundedBorder)
.onChange(of: query) { _, newValue in
onSearch(newValue)
}
if matchCount > 0 {
Text("\(currentIndex + 1)/\(matchCount)")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize()
Button(action: onPrev) {
Image(systemName: "chevron.up")
}
Button(action: onNext) {
Image(systemName: "chevron.down")
}
}
Button(action: onClose) {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
}
.padding(.horizontal)
.padding(.vertical, 6)
.background(.ultraThinMaterial)
}
}