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