SophaxChat · Code Richard Metzner

10 · The iPhone and Mac app

SophaxChat/Views/Chat/MessageBubbleView.swift

527 lines · Swift

// MessageBubbleView.swift
// SophaxChat
//
// Individual message bubble. Renders text, image, and audio content.
// Supports quoted replies, delivery/read status, and context menu actions.

import SwiftUI
import UIKit
import SophaxChatCore

// Wraps UIActivityViewController so file data can be shared / opened in other apps.
struct FileShareSheet: UIViewControllerRepresentable {
    let data:     Data
    let filename: String

    func makeUIViewController(context: Context) -> UIActivityViewController {
        let url = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
        try? data.write(to: url, options: .atomic)
        return UIActivityViewController(activityItems: [url], applicationActivities: nil)
    }
    func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}

// Common emoji reactions
private let reactionEmojis = ["👍", "❤️", "😂", "😮", "😢", "👎"]

struct MessageBubbleView: View {
    @EnvironmentObject var appState: AppState

    let message:        StoredMessage
    var onDelete:       (() -> Void)? = nil
    var onReply:        (() -> Void)? = nil
    var onForward:      (() -> Void)? = nil
    var onEdit:         (() -> Void)? = nil
    var onPin:          (() -> Void)? = nil
    var onAIAction:     ((String) -> Void)? = nil
    /// When false, hides the timestamp/status row (used for grouping consecutive messages).
    var showTimestamp:  Bool = true
    /// Highlight this substring inside text bubbles (search mode).
    var highlightText:  String? = nil

    @State private var attachmentData:    Data?    = nil
    @State private var showFullScreen:    Bool     = false
    @State private var dragOffset:        CGFloat  = 0
    @State private var replyTriggered:    Bool     = false

    private static let replyThreshold: CGFloat = 60

    /// Shared cancellable task for clipboard auto-clear. Static so cancelling it
    /// from any bubble instance reliably cancels the previous timer regardless of
    /// which message was copied last.
    private static var clipboardClearTask: DispatchWorkItem?

    private var isSent: Bool { message.direction == .sent }

    // Look up the message being replied to (if any)
    private var quotedMessage: StoredMessage? {
        guard let id = message.replyToID else { return nil }
        return appState.messages[message.peerID]?.first { $0.id == id }
    }

    var body: some View {
        HStack {
            if isSent { Spacer(minLength: 60) }

            VStack(alignment: isSent ? .trailing : .leading, spacing: 3) {
                // ── Quoted reply (shown above the bubble) ─────────────────────
                if let quoted = quotedMessage {
                    quotedPreviewView(for: quoted)
                        .padding(isSent ? .leading : .trailing, 20)
                }

                // ── Main content ──────────────────────────────────────────────
                contentBubble
                    .contextMenu {
                        let myID = appState.chatManager?.identity.publicIdentity.peerID ?? ""
                        // Emoji reactions
                        Menu {
                            ForEach(reactionEmojis, id: \.self) { emoji in
                                Button {
                                    appState.sendReaction(
                                        emoji: message.reactions?[myID] == emoji ? nil : emoji,
                                        messageID: message.id,
                                        peerID: message.peerID
                                    )
                                } label: {
                                    Text(message.reactions?[myID] == emoji ? "\(emoji) ✓" : emoji)
                                }
                            }
                        } label: {
                            Label("React", systemImage: "face.smiling")
                        }
                        Divider()
                        if let onReply {
                            Button(action: onReply) {
                                Label("Reply", systemImage: "arrowshape.turn.up.left")
                            }
                        }
                        if let onForward {
                            Button(action: onForward) {
                                Label("Forward", systemImage: "arrowshape.turn.up.right")
                            }
                        }
                        if isSent && message.attachmentMimeType == nil
                            && message.timestamp.timeIntervalSinceNow > -300,
                           let onEdit {
                            Button(action: onEdit) {
                                Label("Edit", systemImage: "pencil")
                            }
                        }
                        if let onPin {
                            Button(action: onPin) {
                                Label("Pin Message", systemImage: "pin")
                            }
                        }
                        Divider()
                        if message.attachmentMimeType == nil, let onAIAction {
                            Divider()
                            Menu {
                                Button {
                                    onAIAction("Translate this message to English:\n\n\(message.body)")
                                } label: {
                                    Label("Translate", systemImage: "character.book.closed")
                                }
                                Button {
                                    onAIAction("Analyze this message for phishing, scams, or suspicious content. Be concise:\n\n\(message.body)")
                                } label: {
                                    Label("Check for Threats", systemImage: "shield.lefthalf.filled")
                                }
                            } label: {
                                Label("Ask AI", systemImage: "sparkles")
                            }
                        }
                        Divider()
                        Button {
                            UIPasteboard.general.string = message.body
                            // Cancel any previously scheduled clipboard clear before scheduling
                            // a new one — prevents the race where copying message A then B leaves
                            // A's timer unable to clear (B is now in clipboard) so A lingers forever.
                            // Reduced to 10 s for tighter sensitive-data exposure window.
                            MessageBubbleView.clipboardClearTask?.cancel()
                            let task = DispatchWorkItem { UIPasteboard.general.items = [] }
                            MessageBubbleView.clipboardClearTask = task
                            DispatchQueue.main.asyncAfter(deadline: .now() + 10, execute: task)
                        } label: {
                            Label("Copy", systemImage: "doc.on.doc")
                        }
                        if let onDelete {
                            Divider()
                            Button(role: .destructive, action: onDelete) {
                                Label("Delete", systemImage: "trash")
                            }
                        }
                    }

                // ── Reaction pills ────────────────────────────────────────────
                if let reactions = message.reactions, !reactions.isEmpty {
                    reactionPillsView(reactions: reactions)
                }

                if message.isEdited {
                    Text("edited")
                        .font(.caption2)
                        .foregroundStyle(.tertiary)
                }

                // ── Timestamp + relay hop + disappearing timer + status ───────
                if showTimestamp { HStack(spacing: 4) {
                    Text(message.timestamp, style: .time)
                        .font(.caption2)
                        .foregroundStyle(.tertiary)

                    if let hops = message.hopCount, hops > 0 {
                        Label("\(hops)", systemImage: "arrow.triangle.branch")
                            .font(.caption2)
                            .foregroundStyle(.tertiary)
                            .help("Delivered via \(hops) relay hop\(hops == 1 ? "" : "s")")
                    }

                    if let exp = message.expiresAt, exp > Date() {
                        Image(systemName: "timer")
                            .font(.caption2)
                            .foregroundStyle(.orange)
                        Text(exp, style: .timer)
                            .font(.caption2.monospacedDigit())
                            .foregroundStyle(.orange)
                    }

                    if isSent { statusIcon }
                } }   // closes HStack + if showTimestamp
            }

            if !isSent { Spacer(minLength: 60) }
        }
        .offset(x: dragOffset)
        .background(alignment: .leading) {
            if dragOffset > 0, onReply != nil {
                Image(systemName: "arrowshape.turn.up.left.fill")
                    .font(.system(size: 20))
                    .foregroundStyle(Color.accentColor)
                    .opacity(min(1.0, Double(dragOffset) / Double(Self.replyThreshold) * 1.5))
                    .scaleEffect(min(1.0, dragOffset / Self.replyThreshold))
                    .padding(.leading, 8)
            }
        }
        .gesture(
            DragGesture(minimumDistance: 20)
                .onChanged { value in
                    guard value.translation.width > 0,
                          abs(value.translation.width) > abs(value.translation.height) else { return }
                    dragOffset = min(value.translation.width, 80)
                    if dragOffset >= Self.replyThreshold && !replyTriggered {
                        replyTriggered = true
                        UIImpactFeedbackGenerator(style: .medium).impactOccurred()
                        onReply?()
                    }
                }
                .onEnded { _ in
                    withAnimation(.spring(response: 0.35, dampingFraction: 0.65)) {
                        dragOffset = 0
                    }
                    replyTriggered = false
                }
        )
        .task(id: message.attachmentID) {
            guard let id = message.attachmentID else { return }
            attachmentData = appState.loadAttachment(id: id)
        }
        .sheet(isPresented: $showFullScreen) {
            if let data = attachmentData {
                FullScreenImageView(imageData: data)
            }
        }
    }

    // MARK: - Quoted preview

    @ViewBuilder
    private func quotedPreviewView(for quoted: StoredMessage) -> some View {
        let senderName = quoted.direction == .sent
            ? "You"
            : (appState.peers.first(where: { $0.id == quoted.peerID })
                .map { appState.displayName(for: $0) } ?? "Unknown")

        HStack(spacing: 6) {
            Rectangle()
                .fill(isSent ? Color.white.opacity(0.6) : Color.accentColor)
                .frame(width: 3)
                .clipShape(Capsule())
            VStack(alignment: .leading, spacing: 1) {
                Text(senderName)
                    .font(.caption2.bold())
                    .foregroundStyle(isSent ? .white : .accentColor)
                Text(quoted.body)
                    .font(.caption2)
                    .lineLimit(1)
                    .foregroundStyle(isSent ? .white.opacity(0.75) : .secondary)
            }
        }
        .padding(.horizontal, 10)
        .padding(.vertical, 6)
        .background(isSent ? Color.white.opacity(0.15) : Color(.systemGray6))
        .clipShape(RoundedRectangle(cornerRadius: 10))
    }

    // MARK: - Reaction pills

    @ViewBuilder
    private func reactionPillsView(reactions: [String: String]) -> some View {
        let myID    = appState.chatManager?.identity.publicIdentity.peerID ?? ""
        // Group by emoji, count each
        let grouped = Dictionary(grouping: reactions.values, by: { $0 })
        let sorted  = grouped.sorted { $0.value.count > $1.value.count }

        HStack(spacing: 4) {
            ForEach(sorted, id: \.key) { emoji, peers in
                let isMine = reactions[myID] == emoji
                HStack(spacing: 2) {
                    Text(emoji)
                        .font(.caption)
                    if peers.count > 1 {
                        Text("\(peers.count)")
                            .font(.caption2.bold())
                            .foregroundStyle(isMine ? .white : .primary)
                    }
                }
                .padding(.horizontal, 7)
                .padding(.vertical, 3)
                .background(isMine ? Color.accentColor : Color(.systemGray5))
                .clipShape(Capsule())
                .onTapGesture {
                    // Toggle: tap your own reaction to remove it
                    appState.sendReaction(
                        emoji: isMine ? nil : emoji,
                        messageID: message.id,
                        peerID: message.peerID
                    )
                }
            }
        }
    }

    // MARK: - Content bubble

    @ViewBuilder
    private var contentBubble: some View {
        if message.attachmentMimeType?.hasPrefix("image/") == true {
            imageBubble
        } else if message.attachmentMimeType?.hasPrefix("audio/") == true {
            audioBubble
        } else if message.attachmentMimeType?.hasPrefix("video/") == true {
            videoBubble
        } else if message.attachmentID != nil && message.attachmentMimeType != nil {
            fileBubble
        } else {
            textBubble
        }
    }

    // MARK: - Text bubble

    private var textBubble: some View {
        highlightedView(message.body)
            .font(.body)
            .foregroundStyle(isSent ? .white : .primary)
            .padding(.horizontal, 14)
            .padding(.vertical, 10)
            .background(isSent ? Color.accentColor : Color(.secondarySystemGroupedBackground))
            .clipShape(RoundedRectangle(cornerRadius: 18))
    }

    /// Returns a Text view with `highlightText` ranges marked in yellow/black.
    private func highlightedView(_ text: String) -> Text {
        guard let query = highlightText, !query.isEmpty else { return Text(text) }
        var attr = AttributedString(text)
        let nsText = text as NSString
        var searchRange = NSRange(location: 0, length: nsText.length)
        while true {
            let found = nsText.range(of: query, options: .caseInsensitive, range: searchRange)
            guard found.location != NSNotFound else { break }
            if let swiftRange = Range(found, in: text), let attrRange = Range(swiftRange, in: attr) {
                attr[attrRange].backgroundColor = Color.yellow
                attr[attrRange].foregroundColor = Color.black
            }
            let next = found.upperBound
            guard next < nsText.length else { break }
            searchRange = NSRange(location: next, length: nsText.length - next)
        }
        return Text(attr)
    }

    // MARK: - Image bubble

    @ViewBuilder
    private var imageBubble: some View {
        if let data = attachmentData, let uiImage = UIImage(data: data) {
            Image(uiImage: uiImage)
                .resizable()
                .scaledToFill()
                .frame(maxWidth: 220, maxHeight: 200)
                .clipped()
                .clipShape(RoundedRectangle(cornerRadius: 14))
                .overlay(
                    RoundedRectangle(cornerRadius: 14)
                        .stroke(isSent ? Color.accentColor.opacity(0.4) : Color.secondary.opacity(0.2), lineWidth: 1)
                )
                .onTapGesture { showFullScreen = true }
                .overlay(alignment: .bottomTrailing) {
                    if !message.body.isEmpty && message.body != "📷 Photo" {
                        Text(message.body)
                            .font(.caption)
                            .foregroundStyle(.white)
                            .padding(6)
                            .background(.black.opacity(0.45))
                            .clipShape(RoundedRectangle(cornerRadius: 8))
                            .padding(6)
                    }
                }
        } else {
            // Placeholder while loading
            RoundedRectangle(cornerRadius: 14)
                .fill(Color(.systemGray5))
                .frame(width: 180, height: 140)
                .overlay {
                    if message.status == .sending {
                        ProgressView()
                    } else {
                        Image(systemName: "photo")
                            .font(.system(size: 32))
                            .foregroundStyle(.tertiary)
                    }
                }
        }
    }

    // MARK: - Audio bubble

    @ViewBuilder
    private var audioBubble: some View {
        if let data = attachmentData {
            AudioMessageView(
                audioData: data,
                duration:  message.audioDuration ?? 0,
                isSent:    isSent
            )
            .frame(minWidth: 200)
            .background(isSent ? Color.accentColor : Color(.secondarySystemGroupedBackground))
            .clipShape(RoundedRectangle(cornerRadius: 18))
        } else {
            HStack(spacing: 8) {
                Image(systemName: "waveform")
                    .foregroundStyle(.tertiary)
                Text("Voice message")
                    .font(.body)
                    .foregroundStyle(isSent ? .white : .primary)
            }
            .padding(.horizontal, 14)
            .padding(.vertical, 10)
            .background(isSent ? Color.accentColor : Color(.secondarySystemGroupedBackground))
            .clipShape(RoundedRectangle(cornerRadius: 18))
        }
    }

    // MARK: - Video bubble

    @ViewBuilder
    private var videoBubble: some View {
        if let data = attachmentData {
            VideoMessageView(data: data, isSent: isSent)
        } else {
            RoundedRectangle(cornerRadius: 14)
                .fill(Color(.systemGray5))
                .frame(width: 220, height: 160)
                .overlay {
                    if message.status == .sending {
                        ProgressView()
                    } else {
                        Image(systemName: "video")
                            .font(.system(size: 32))
                            .foregroundStyle(.tertiary)
                    }
                }
        }
    }

    // MARK: - File bubble

    @State private var showFileShare = false

    private var fileBubble: some View {
        Button { showFileShare = true } label: {
            HStack(spacing: 10) {
                Image(systemName: "doc.fill")
                    .font(.system(size: 28))
                    .foregroundStyle(isSent ? .white : Color.accentColor)
                VStack(alignment: .leading, spacing: 2) {
                    Text(message.attachmentFilename ?? "File")
                        .font(.body.weight(.medium))
                        .foregroundStyle(isSent ? .white : .primary)
                        .lineLimit(2)
                    if let mime = message.attachmentMimeType {
                        Text(mime)
                            .font(.caption2)
                            .foregroundStyle(isSent ? .white.opacity(0.7) : .secondary)
                    }
                }
            }
            .padding(.horizontal, 14)
            .padding(.vertical, 12)
            .background(isSent ? Color.accentColor : Color(.secondarySystemGroupedBackground))
            .clipShape(RoundedRectangle(cornerRadius: 18))
        }
        .buttonStyle(.plain)
        .sheet(isPresented: $showFileShare) {
            if let data = attachmentData {
                FileShareSheet(data: data, filename: message.attachmentFilename ?? "file")
            }
        }
    }

    // MARK: - Status icon

    @ViewBuilder
    private var statusIcon: some View {
        switch message.status {
        case .sending:
            Image(systemName: "clock")
                .font(.caption2)
                .foregroundStyle(.tertiary)
        case .delivered:
            Image(systemName: "checkmark.circle.fill")
                .font(.caption2)
                .foregroundStyle(.green)
        case .read:
            Image(systemName: "checkmark.circle.fill")
                .font(.caption2)
                .foregroundStyle(Color.accentColor)
        case .failed:
            Image(systemName: "exclamationmark.circle")
                .font(.caption2)
                .foregroundStyle(.red)
        }
    }
}

#Preview {
    VStack(spacing: 8) {
        MessageBubbleView(message: StoredMessage(
            peerID: "test", direction: .sent, body: "Hey, this is encrypted!",
            status: .delivered
        ))
        MessageBubbleView(message: StoredMessage(
            peerID: "test", direction: .received, body: "Absolutely secure 🔒",
            status: .delivered
        ))
        MessageBubbleView(message: StoredMessage(
            peerID: "test", direction: .sent, body: "Sending...",
            status: .sending
        ))
        MessageBubbleView(message: StoredMessage(
            peerID: "test", direction: .sent, body: "Read!",
            status: .read
        ))
    }
    .padding()
    .environmentObject(AppState())
}