mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 22:25:20 +00:00
file transfer wip
This commit is contained in:
@@ -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.
|
||||||
@@ -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 <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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 <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user