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.. 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.. 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) {} }