ios_client
This commit is contained in:
558
ios_client 0.8.5/Kecalek/Views/Chat/MessageBubbleView.swift
Normal file
558
ios_client 0.8.5/Kecalek/Views/Chat/MessageBubbleView.swift
Normal 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) {}
|
||||
}
|
||||
Reference in New Issue
Block a user