1// MessageBubbleView.swift
2// SophaxChat
3//
4// Individual message bubble. Renders text, image, and audio content.
5// Supports quoted replies, delivery/read status, and context menu actions.
6
7import SwiftUI
8import UIKit
9import SophaxChatCore
10
11// Wraps UIActivityViewController so file data can be shared / opened in other apps.
12struct FileShareSheet: UIViewControllerRepresentable {
13 let data: Data
14 let filename: String
15
16 func makeUIViewController(context: Context) -> UIActivityViewController {
17 let url = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
18 try? data.write(to: url, options: .atomic)
19 return UIActivityViewController(activityItems: [url], applicationActivities: nil)
20 }
21 func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
22}
23
24// Common emoji reactions
25private let reactionEmojis = ["👍", "❤️", "😂", "😮", "😢", "👎"]
26
27struct MessageBubbleView: View {
28 @EnvironmentObject var appState: AppState
29
30 let message: StoredMessage
31 var onDelete: (() -> Void)? = nil
32 var onReply: (() -> Void)? = nil
33 var onForward: (() -> Void)? = nil
34 var onEdit: (() -> Void)? = nil
35 var onPin: (() -> Void)? = nil
36 var onAIAction: ((String) -> Void)? = nil
37 /// When false, hides the timestamp/status row (used for grouping consecutive messages).
38 var showTimestamp: Bool = true
39 /// Highlight this substring inside text bubbles (search mode).
40 var highlightText: String? = nil
41
42 @State private var attachmentData: Data? = nil
43 @State private var showFullScreen: Bool = false
44 @State private var dragOffset: CGFloat = 0
45 @State private var replyTriggered: Bool = false
46
47 private static let replyThreshold: CGFloat = 60
48
49 /// Shared cancellable task for clipboard auto-clear. Static so cancelling it
50 /// from any bubble instance reliably cancels the previous timer regardless of
51 /// which message was copied last.
52 private static var clipboardClearTask: DispatchWorkItem?
53
54 private var isSent: Bool { message.direction == .sent }
55
56 // Look up the message being replied to (if any)
57 private var quotedMessage: StoredMessage? {
58 guard let id = message.replyToID else { return nil }
59 return appState.messages[message.peerID]?.first { $0.id == id }
60 }
61
62 var body: some View {
63 HStack {
64 if isSent { Spacer(minLength: 60) }
65
66 VStack(alignment: isSent ? .trailing : .leading, spacing: 3) {
67 // ── Quoted reply (shown above the bubble) ─────────────────────
68 if let quoted = quotedMessage {
69 quotedPreviewView(for: quoted)
70 .padding(isSent ? .leading : .trailing, 20)
71 }
72
73 // ── Main content ──────────────────────────────────────────────
74 contentBubble
75 .contextMenu {
76 let myID = appState.chatManager?.identity.publicIdentity.peerID ?? ""
77 // Emoji reactions
78 Menu {
79 ForEach(reactionEmojis, id: \.self) { emoji in
80 Button {
81 appState.sendReaction(
82 emoji: message.reactions?[myID] == emoji ? nil : emoji,
83 messageID: message.id,
84 peerID: message.peerID
85 )
86 } label: {
87 Text(message.reactions?[myID] == emoji ? "\(emoji) ✓" : emoji)
88 }
89 }
90 } label: {
91 Label("React", systemImage: "face.smiling")
92 }
93 Divider()
94 if let onReply {
95 Button(action: onReply) {
96 Label("Reply", systemImage: "arrowshape.turn.up.left")
97 }
98 }
99 if let onForward {
100 Button(action: onForward) {
101 Label("Forward", systemImage: "arrowshape.turn.up.right")
102 }
103 }
104 if isSent && message.attachmentMimeType == nil
105 && message.timestamp.timeIntervalSinceNow > -300,
106 let onEdit {
107 Button(action: onEdit) {
108 Label("Edit", systemImage: "pencil")
109 }
110 }
111 if let onPin {
112 Button(action: onPin) {
113 Label("Pin Message", systemImage: "pin")
114 }
115 }
116 Divider()
117 if message.attachmentMimeType == nil, let onAIAction {
118 Divider()
119 Menu {
120 Button {
121 onAIAction("Translate this message to English:\n\n\(message.body)")
122 } label: {
123 Label("Translate", systemImage: "character.book.closed")
124 }
125 Button {
126 onAIAction("Analyze this message for phishing, scams, or suspicious content. Be concise:\n\n\(message.body)")
127 } label: {
128 Label("Check for Threats", systemImage: "shield.lefthalf.filled")
129 }
130 } label: {
131 Label("Ask AI", systemImage: "sparkles")
132 }
133 }
134 Divider()
135 Button {
136 UIPasteboard.general.string = message.body
137 // Cancel any previously scheduled clipboard clear before scheduling
138 // a new one — prevents the race where copying message A then B leaves
139 // A's timer unable to clear (B is now in clipboard) so A lingers forever.
140 // Reduced to 10 s for tighter sensitive-data exposure window.
141 MessageBubbleView.clipboardClearTask?.cancel()
142 let task = DispatchWorkItem { UIPasteboard.general.items = [] }
143 MessageBubbleView.clipboardClearTask = task
144 DispatchQueue.main.asyncAfter(deadline: .now() + 10, execute: task)
145 } label: {
146 Label("Copy", systemImage: "doc.on.doc")
147 }
148 if let onDelete {
149 Divider()
150 Button(role: .destructive, action: onDelete) {
151 Label("Delete", systemImage: "trash")
152 }
153 }
154 }
155
156 // ── Reaction pills ────────────────────────────────────────────
157 if let reactions = message.reactions, !reactions.isEmpty {
158 reactionPillsView(reactions: reactions)
159 }
160
161 if message.isEdited {
162 Text("edited")
163 .font(.caption2)
164 .foregroundStyle(.tertiary)
165 }
166
167 // ── Timestamp + relay hop + disappearing timer + status ───────
168 if showTimestamp { HStack(spacing: 4) {
169 Text(message.timestamp, style: .time)
170 .font(.caption2)
171 .foregroundStyle(.tertiary)
172
173 if let hops = message.hopCount, hops > 0 {
174 Label("\(hops)", systemImage: "arrow.triangle.branch")
175 .font(.caption2)
176 .foregroundStyle(.tertiary)
177 .help("Delivered via \(hops) relay hop\(hops == 1 ? "" : "s")")
178 }
179
180 if let exp = message.expiresAt, exp > Date() {
181 Image(systemName: "timer")
182 .font(.caption2)
183 .foregroundStyle(.orange)
184 Text(exp, style: .timer)
185 .font(.caption2.monospacedDigit())
186 .foregroundStyle(.orange)
187 }
188
189 if isSent { statusIcon }
190 } } // closes HStack + if showTimestamp
191 }
192
193 if !isSent { Spacer(minLength: 60) }
194 }
195 .offset(x: dragOffset)
196 .background(alignment: .leading) {
197 if dragOffset > 0, onReply != nil {
198 Image(systemName: "arrowshape.turn.up.left.fill")
199 .font(.system(size: 20))
200 .foregroundStyle(Color.accentColor)
201 .opacity(min(1.0, Double(dragOffset) / Double(Self.replyThreshold) * 1.5))
202 .scaleEffect(min(1.0, dragOffset / Self.replyThreshold))
203 .padding(.leading, 8)
204 }
205 }
206 .gesture(
207 DragGesture(minimumDistance: 20)
208 .onChanged { value in
209 guard value.translation.width > 0,
210 abs(value.translation.width) > abs(value.translation.height) else { return }
211 dragOffset = min(value.translation.width, 80)
212 if dragOffset >= Self.replyThreshold && !replyTriggered {
213 replyTriggered = true
214 UIImpactFeedbackGenerator(style: .medium).impactOccurred()
215 onReply?()
216 }
217 }
218 .onEnded { _ in
219 withAnimation(.spring(response: 0.35, dampingFraction: 0.65)) {
220 dragOffset = 0
221 }
222 replyTriggered = false
223 }
224 )
225 .task(id: message.attachmentID) {
226 guard let id = message.attachmentID else { return }
227 attachmentData = appState.loadAttachment(id: id)
228 }
229 .sheet(isPresented: $showFullScreen) {
230 if let data = attachmentData {
231 FullScreenImageView(imageData: data)
232 }
233 }
234 }
235
236 // MARK: - Quoted preview
237
238 @ViewBuilder
239 private func quotedPreviewView(for quoted: StoredMessage) -> some View {
240 let senderName = quoted.direction == .sent
241 ? "You"
242 : (appState.peers.first(where: { $0.id == quoted.peerID })
243 .map { appState.displayName(for: $0) } ?? "Unknown")
244
245 HStack(spacing: 6) {
246 Rectangle()
247 .fill(isSent ? Color.white.opacity(0.6) : Color.accentColor)
248 .frame(width: 3)
249 .clipShape(Capsule())
250 VStack(alignment: .leading, spacing: 1) {
251 Text(senderName)
252 .font(.caption2.bold())
253 .foregroundStyle(isSent ? .white : .accentColor)
254 Text(quoted.body)
255 .font(.caption2)
256 .lineLimit(1)
257 .foregroundStyle(isSent ? .white.opacity(0.75) : .secondary)
258 }
259 }
260 .padding(.horizontal, 10)
261 .padding(.vertical, 6)
262 .background(isSent ? Color.white.opacity(0.15) : Color(.systemGray6))
263 .clipShape(RoundedRectangle(cornerRadius: 10))
264 }
265
266 // MARK: - Reaction pills
267
268 @ViewBuilder
269 private func reactionPillsView(reactions: [String: String]) -> some View {
270 let myID = appState.chatManager?.identity.publicIdentity.peerID ?? ""
271 // Group by emoji, count each
272 let grouped = Dictionary(grouping: reactions.values, by: { $0 })
273 let sorted = grouped.sorted { $0.value.count > $1.value.count }
274
275 HStack(spacing: 4) {
276 ForEach(sorted, id: \.key) { emoji, peers in
277 let isMine = reactions[myID] == emoji
278 HStack(spacing: 2) {
279 Text(emoji)
280 .font(.caption)
281 if peers.count > 1 {
282 Text("\(peers.count)")
283 .font(.caption2.bold())
284 .foregroundStyle(isMine ? .white : .primary)
285 }
286 }
287 .padding(.horizontal, 7)
288 .padding(.vertical, 3)
289 .background(isMine ? Color.accentColor : Color(.systemGray5))
290 .clipShape(Capsule())
291 .onTapGesture {
292 // Toggle: tap your own reaction to remove it
293 appState.sendReaction(
294 emoji: isMine ? nil : emoji,
295 messageID: message.id,
296 peerID: message.peerID
297 )
298 }
299 }
300 }
301 }
302
303 // MARK: - Content bubble
304
305 @ViewBuilder
306 private var contentBubble: some View {
307 if message.attachmentMimeType?.hasPrefix("image/") == true {
308 imageBubble
309 } else if message.attachmentMimeType?.hasPrefix("audio/") == true {
310 audioBubble
311 } else if message.attachmentMimeType?.hasPrefix("video/") == true {
312 videoBubble
313 } else if message.attachmentID != nil && message.attachmentMimeType != nil {
314 fileBubble
315 } else {
316 textBubble
317 }
318 }
319
320 // MARK: - Text bubble
321
322 private var textBubble: some View {
323 highlightedView(message.body)
324 .font(.body)
325 .foregroundStyle(isSent ? .white : .primary)
326 .padding(.horizontal, 14)
327 .padding(.vertical, 10)
328 .background(isSent ? Color.accentColor : Color(.secondarySystemGroupedBackground))
329 .clipShape(RoundedRectangle(cornerRadius: 18))
330 }
331
332 /// Returns a Text view with `highlightText` ranges marked in yellow/black.
333 private func highlightedView(_ text: String) -> Text {
334 guard let query = highlightText, !query.isEmpty else { return Text(text) }
335 var attr = AttributedString(text)
336 let nsText = text as NSString
337 var searchRange = NSRange(location: 0, length: nsText.length)
338 while true {
339 let found = nsText.range(of: query, options: .caseInsensitive, range: searchRange)
340 guard found.location != NSNotFound else { break }
341 if let swiftRange = Range(found, in: text), let attrRange = Range(swiftRange, in: attr) {
342 attr[attrRange].backgroundColor = Color.yellow
343 attr[attrRange].foregroundColor = Color.black
344 }
345 let next = found.upperBound
346 guard next < nsText.length else { break }
347 searchRange = NSRange(location: next, length: nsText.length - next)
348 }
349 return Text(attr)
350 }
351
352 // MARK: - Image bubble
353
354 @ViewBuilder
355 private var imageBubble: some View {
356 if let data = attachmentData, let uiImage = UIImage(data: data) {
357 Image(uiImage: uiImage)
358 .resizable()
359 .scaledToFill()
360 .frame(maxWidth: 220, maxHeight: 200)
361 .clipped()
362 .clipShape(RoundedRectangle(cornerRadius: 14))
363 .overlay(
364 RoundedRectangle(cornerRadius: 14)
365 .stroke(isSent ? Color.accentColor.opacity(0.4) : Color.secondary.opacity(0.2), lineWidth: 1)
366 )
367 .onTapGesture { showFullScreen = true }
368 .overlay(alignment: .bottomTrailing) {
369 if !message.body.isEmpty && message.body != "📷 Photo" {
370 Text(message.body)
371 .font(.caption)
372 .foregroundStyle(.white)
373 .padding(6)
374 .background(.black.opacity(0.45))
375 .clipShape(RoundedRectangle(cornerRadius: 8))
376 .padding(6)
377 }
378 }
379 } else {
380 // Placeholder while loading
381 RoundedRectangle(cornerRadius: 14)
382 .fill(Color(.systemGray5))
383 .frame(width: 180, height: 140)
384 .overlay {
385 if message.status == .sending {
386 ProgressView()
387 } else {
388 Image(systemName: "photo")
389 .font(.system(size: 32))
390 .foregroundStyle(.tertiary)
391 }
392 }
393 }
394 }
395
396 // MARK: - Audio bubble
397
398 @ViewBuilder
399 private var audioBubble: some View {
400 if let data = attachmentData {
401 AudioMessageView(
402 audioData: data,
403 duration: message.audioDuration ?? 0,
404 isSent: isSent
405 )
406 .frame(minWidth: 200)
407 .background(isSent ? Color.accentColor : Color(.secondarySystemGroupedBackground))
408 .clipShape(RoundedRectangle(cornerRadius: 18))
409 } else {
410 HStack(spacing: 8) {
411 Image(systemName: "waveform")
412 .foregroundStyle(.tertiary)
413 Text("Voice message")
414 .font(.body)
415 .foregroundStyle(isSent ? .white : .primary)
416 }
417 .padding(.horizontal, 14)
418 .padding(.vertical, 10)
419 .background(isSent ? Color.accentColor : Color(.secondarySystemGroupedBackground))
420 .clipShape(RoundedRectangle(cornerRadius: 18))
421 }
422 }
423
424 // MARK: - Video bubble
425
426 @ViewBuilder
427 private var videoBubble: some View {
428 if let data = attachmentData {
429 VideoMessageView(data: data, isSent: isSent)
430 } else {
431 RoundedRectangle(cornerRadius: 14)
432 .fill(Color(.systemGray5))
433 .frame(width: 220, height: 160)
434 .overlay {
435 if message.status == .sending {
436 ProgressView()
437 } else {
438 Image(systemName: "video")
439 .font(.system(size: 32))
440 .foregroundStyle(.tertiary)
441 }
442 }
443 }
444 }
445
446 // MARK: - File bubble
447
448 @State private var showFileShare = false
449
450 private var fileBubble: some View {
451 Button { showFileShare = true } label: {
452 HStack(spacing: 10) {
453 Image(systemName: "doc.fill")
454 .font(.system(size: 28))
455 .foregroundStyle(isSent ? .white : Color.accentColor)
456 VStack(alignment: .leading, spacing: 2) {
457 Text(message.attachmentFilename ?? "File")
458 .font(.body.weight(.medium))
459 .foregroundStyle(isSent ? .white : .primary)
460 .lineLimit(2)
461 if let mime = message.attachmentMimeType {
462 Text(mime)
463 .font(.caption2)
464 .foregroundStyle(isSent ? .white.opacity(0.7) : .secondary)
465 }
466 }
467 }
468 .padding(.horizontal, 14)
469 .padding(.vertical, 12)
470 .background(isSent ? Color.accentColor : Color(.secondarySystemGroupedBackground))
471 .clipShape(RoundedRectangle(cornerRadius: 18))
472 }
473 .buttonStyle(.plain)
474 .sheet(isPresented: $showFileShare) {
475 if let data = attachmentData {
476 FileShareSheet(data: data, filename: message.attachmentFilename ?? "file")
477 }
478 }
479 }
480
481 // MARK: - Status icon
482
483 @ViewBuilder
484 private var statusIcon: some View {
485 switch message.status {
486 case .sending:
487 Image(systemName: "clock")
488 .font(.caption2)
489 .foregroundStyle(.tertiary)
490 case .delivered:
491 Image(systemName: "checkmark.circle.fill")
492 .font(.caption2)
493 .foregroundStyle(.green)
494 case .read:
495 Image(systemName: "checkmark.circle.fill")
496 .font(.caption2)
497 .foregroundStyle(Color.accentColor)
498 case .failed:
499 Image(systemName: "exclamationmark.circle")
500 .font(.caption2)
501 .foregroundStyle(.red)
502 }
503 }
504}
505
506#Preview {
507 VStack(spacing: 8) {
508 MessageBubbleView(message: StoredMessage(
509 peerID: "test", direction: .sent, body: "Hey, this is encrypted!",
510 status: .delivered
511 ))
512 MessageBubbleView(message: StoredMessage(
513 peerID: "test", direction: .received, body: "Absolutely secure 🔒",
514 status: .delivered
515 ))
516 MessageBubbleView(message: StoredMessage(
517 peerID: "test", direction: .sent, body: "Sending...",
518 status: .sending
519 ))
520 MessageBubbleView(message: StoredMessage(
521 peerID: "test", direction: .sent, body: "Read!",
522 status: .read
523 ))
524 }
525 .padding()
526 .environmentObject(AppState())
527}