diff --git a/bitchat/ViewModels/ChatViewModel+Extensions.swift b/bitchat/ViewModels/ChatViewModel+Extensions.swift new file mode 100644 index 00000000..20e62008 --- /dev/null +++ b/bitchat/ViewModels/ChatViewModel+Extensions.swift @@ -0,0 +1,7 @@ +// ChatViewModel+Extensions.swift +// bitchat +// +// Deprecated helper. No longer needed after MessageHeaderLine simplification. +// Intentionally left empty to avoid accessing private properties across files. + +// This file remains as a placeholder to preserve project references if any. diff --git a/bitchat/ViewModels/ChatViewModel+Images.swift b/bitchat/ViewModels/ChatViewModel+Images.swift new file mode 100644 index 00000000..226b2643 --- /dev/null +++ b/bitchat/ViewModels/ChatViewModel+Images.swift @@ -0,0 +1,113 @@ +// ChatViewModel+Images.swift +// bitchat +// +// Extracted image sending helpers to keep ChatViewModel compact. +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +#if os(iOS) +import UIKit +#endif + +extension ChatViewModel { + // MARK: - Images (Send) + #if os(iOS) + @MainActor + func sendImage(_ image: UIImage) { + // Downscale on long edge to 512px with 85% JPEG quality (Android parity) + guard let data = downscaleJPEG(image, maxDimension: 512, quality: 0.85) else { return } + + // Persist to app files (outgoing) + guard let fileURL = saveOutgoingImage(data: data) else { return } + + let name = fileURL.lastPathComponent + let mime = "image/jpeg" + let tlv = BitchatFilePacket(fileName: name, fileSize: UInt64(data.count), mimeType: mime, content: data) + guard let payload = tlv.encode() else { return } + let transferId = payload.sha256Hex() + + let contentMarker = "[image] \(fileURL.path)" + let now = Date() + + if let peer = selectedPrivateChatPeer { + let messageID = UUID().uuidString + let msg = BitchatMessage( + id: messageID, + sender: nickname, + content: contentMarker, + timestamp: now, + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: meshService.peerNickname(peerID: peer), + senderPeerID: meshService.myPeerID, + mentions: nil, + deliveryStatus: .sending + ) + // appendToPrivateChat is private in ChatViewModel; inline minimal append + var arr = privateChats[peer] ?? [] + arr.append(msg) + privateChats[peer] = arr + objectWillChange.send() + meshService.sendFileTransferTLV(payload, recipientPeerID: peer, transferId: transferId, messageID: messageID) + } else { + let messageID = UUID().uuidString + let msg = BitchatMessage( + id: messageID, + sender: nickname, + content: contentMarker, + timestamp: now, + isRelay: false, + originalSender: nil, + isPrivate: false, + recipientNickname: nil, + senderPeerID: meshService.myPeerID, + mentions: nil, + deliveryStatus: .sending + ) + // public buffer helpers are private; append directly to visible messages + messages.append(msg) + meshService.sendFileTransferTLV(payload, recipientPeerID: nil, transferId: transferId, messageID: messageID) + } + } + + // Downscale without cropping; preserves aspect ratio. Long edge == maxDimension. + internal func downscaleJPEG(_ image: UIImage, maxDimension: CGFloat, quality: CGFloat) -> Data? { + let size = image.size + guard size.width > 0 && size.height > 0 else { return image.jpegData(compressionQuality: quality) } + let maxSide = max(size.width, size.height) + let scale = min(1.0, maxDimension / maxSide) + let target = CGSize(width: floor(size.width * scale), height: floor(size.height * scale)) + + let rendererFormat = UIGraphicsImageRendererFormat.default() + rendererFormat.scale = 1 + let renderer = UIGraphicsImageRenderer(size: target, format: rendererFormat) + let scaled = renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: target)) + } + return scaled.jpegData(compressionQuality: quality) + } + + internal func saveOutgoingImage(data: Data) -> URL? { + do { + let fm = FileManager.default + let base = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let folder = base.appendingPathComponent("bitchat_images/outgoing", isDirectory: true) + if !fm.fileExists(atPath: folder.path) { + try fm.createDirectory(at: folder, withIntermediateDirectories: true) + } + let ts = Int(Date().timeIntervalSince1970) + let fileURL = folder.appendingPathComponent("img_\(ts)_\(UUID().uuidString.prefix(8)).jpg") + try data.write(to: fileURL, options: .atomic) + return fileURL + } catch { + // Avoid BitLogger dependency in this small extension + print("❌ Failed to save outgoing image: \(error)") + return nil + } + } + #endif +} diff --git a/bitchat/Views/ImageMessageRow.swift b/bitchat/Views/ImageMessageRow.swift new file mode 100644 index 00000000..19116014 --- /dev/null +++ b/bitchat/Views/ImageMessageRow.swift @@ -0,0 +1,50 @@ +// ImageMessageRow.swift +// bitchat +// +// Renders an image message bubble with rounded corners and optional delivery status. +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import SwiftUI +#if os(iOS) +import UIKit +#endif + +struct ImageMessageRow: View { + let path: String + let message: BitchatMessage + @Environment(\.colorScheme) var colorScheme + @EnvironmentObject var viewModel: ChatViewModel + + var body: some View { + #if os(iOS) + if let uiImage = UIImage(contentsOfFile: path) { + VStack(alignment: .leading, spacing: 4) { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 200, maxHeight: 200) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(radius: 2) + if message.isPrivate && message.sender == viewModel.nickname, + let status = message.deliveryStatus { + DeliveryStatusView(status: status, colorScheme: colorScheme) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } else { + Text("⚠️ Image unavailable") + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + #else + Text("📷 Image: \(path)") + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(colorScheme == .dark ? .green : Color(red: 0, green: 0.5, blue: 0)) + .frame(maxWidth: .infinity, alignment: .leading) + #endif + } +} diff --git a/bitchat/Views/MessageHeaderLine.swift b/bitchat/Views/MessageHeaderLine.swift new file mode 100644 index 00000000..b5080a28 --- /dev/null +++ b/bitchat/Views/MessageHeaderLine.swift @@ -0,0 +1,71 @@ +// MessageHeaderLine.swift +// bitchat +// +// Renders a header like normal messages: "<@nickname> [HH:mm:ss]" +// Used above media rows (images, audio) to match text message style. +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import SwiftUI + +struct MessageHeaderLine: View { + @EnvironmentObject var viewModel: ChatViewModel + @Environment(\.colorScheme) var colorScheme + let message: BitchatMessage + + var body: some View { + Text(formattedHeader()) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 2) + } + + private func formattedHeader() -> AttributedString { + var result = AttributedString() + let isDark = colorScheme == .dark + let baseColor = viewModel.peerColor(for: message, isDark: isDark) + + // Determine self to bold our own name + let isSelf: Bool = { + if let spid = message.senderPeerID { + return spid == viewModel.meshService.myPeerID + } + return message.sender == viewModel.nickname || message.sender.hasPrefix(viewModel.nickname + "#") + }() + + // Split suffix like name#abcd + let (base, suffix) = message.sender.splitSuffix() + + var senderStyle = AttributeContainer() + senderStyle.foregroundColor = baseColor + senderStyle.font = .system(size: 14, weight: isSelf ? .bold : .medium, design: .monospaced) + if let spid = message.senderPeerID, + let url = URL(string: "bitchat://user/\(spid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid)") { + senderStyle.link = url + } + + // "<@" + result.append(AttributedString("<@").mergingAttributes(senderStyle)) + // base name + result.append(AttributedString(base).mergingAttributes(senderStyle)) + // optional suffix with lighter color + if !suffix.isEmpty { + var suf = senderStyle + suf.foregroundColor = baseColor.opacity(0.6) + result.append(AttributedString(suffix).mergingAttributes(suf)) + } + // Close angle without adding content or ">" + result.append(AttributedString("> ").mergingAttributes(senderStyle)) + + // Timestamp like normal messages + let ts = AttributedString("[\(message.formattedTimestamp)]") + var tsStyle = AttributeContainer() + tsStyle.foregroundColor = Color.gray.opacity(0.7) + tsStyle.font = .system(size: 10, design: .monospaced) + result.append(ts.mergingAttributes(tsStyle)) + + return result + } +}