Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions MeshChatMVP/BluetoothMeshService+Comments.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import Foundation

/// Extension that wires label comment state + sending into the existing mesh service.
/// Add the `@Published var labelComments` property to BluetoothMeshService directly,
/// or use this approach: we store comments in a shared store accessed via this extension.
///
/// TO INTEGRATE: Add the following to BluetoothMeshService's stored properties:
/// @Published var labelComments: [UUID: [LabelComment]] = [:]
///
/// And in the envelope-handling switch add a case for `.labelComment` that calls `handleLabelComment(_:)`.
///
/// The stubs below compile against the existing type; replace with real BLE send when ready.

extension BluetoothMeshService {

/// Send a comment on an alert label.
/// Wire this to a new `.labelComment` MessageType for real mesh delivery.
func sendLabelComment(labelId: UUID, text: String, imageJPEGBase64: String? = nil) {
let comment = LabelComment(
id: UUID(),
labelId: labelId,
senderID: identity.deviceID,
senderName: identity.nickname,
text: text,
imageJPEGBase64: imageJPEGBase64,
date: Date(),
isLocal: true
)
DispatchQueue.main.async {
var map = self.labelComments
var list = map[labelId] ?? []
list.append(comment)
map[labelId] = list
self.labelComments = map
}

// TODO: encode as LabelCommentPayload and relay via BLE
// let payload = LabelCommentPayload(...)
// enqueue(payload, type: .labelComment)
}

/// Ingest a comment received from a peer (call this from your BLE receive handler).
func handleLabelComment(_ payload: LabelCommentPayload) {
let comment = LabelComment(
id: payload.commentId,
labelId: payload.labelId,
senderID: payload.senderID,
senderName: payload.senderName,
text: payload.text,
imageJPEGBase64: payload.imageJPEGBase64,
date: Date(timeIntervalSince1970: Double(payload.timestamp) / 1000),
isLocal: false
)
DispatchQueue.main.async {
var map = self.labelComments
var list = map[payload.labelId] ?? []
// Deduplicate by commentId
guard !list.contains(where: { $0.id == payload.commentId }) else { return }
list.append(comment)
list.sort { $0.date < $1.date }
map[payload.labelId] = list
self.labelComments = map
}
}
}
84 changes: 84 additions & 0 deletions MeshChatMVP/BluetoothMeshService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ final class BluetoothMeshService: NSObject, ObservableObject {
/// Label votes: labelId → (voterID → 1 or -1); shared via .mapLabelVote envelopes.
@Published var labelVotes: [UUID: [String: Int]] = [:]

/// Comments (forum-style thread) per label: labelId → sorted array of LabelComment.
@Published var labelComments: [UUID: [LabelComment]] = [:]

/// Seconds remaining before this user can post another map label (30s cooldown).
@Published var mapLabelCooldownRemaining: Double = 0

Expand Down Expand Up @@ -86,6 +89,14 @@ final class BluetoothMeshService: NSObject, ObservableObject {
private var pruneTimer: Timer?
/// Single upsert map for discovered peers (avoids duplicate rows from concurrent main-queue updates).
private var discoveredPeerById: [UUID: DiscoveredPeer] = [:]
/// Chat history TTL (persisted messages older than this are dropped).
private let chatHistoryTTLSeconds: TimeInterval = 20 * 60
private var chatHistoryURL: URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("MeshChatMVP", isDirectory: true)
.appendingPathComponent("chat_history.json")
}

private var scanIdleWorkItem: DispatchWorkItem?
private var scanCountdownTimer: Timer?
private var nextScanDeadline: Date?
Expand Down Expand Up @@ -121,7 +132,9 @@ final class BluetoothMeshService: NSObject, ObservableObject {
locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
locationManager.requestWhenInUseAuthorization()
startPruneTimer()
loadChatHistory()
requestNotificationAuthIfNeeded()
startChatHistoryPruneTimer()
}

deinit {
Expand Down Expand Up @@ -897,6 +910,77 @@ final class BluetoothMeshService: NSObject, ObservableObject {

private func appendChatMessage(_ m: ChatMessage) {
chatMessages.append(m)
pruneChatByTTL()
saveChatHistory()
}

private func pruneChatByTTL() {
let cutoff = Date().addingTimeInterval(-chatHistoryTTLSeconds)
chatMessages.removeAll { $0.date < cutoff }
}

private func loadChatHistory() {
let dir = chatHistoryURL.deletingLastPathComponent()
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
guard let data = try? Data(contentsOf: chatHistoryURL),
let decoded = try? JSONDecoder().decode([ChatMessage].self, from: data)
else { return }
let cutoff = Date().addingTimeInterval(-chatHistoryTTLSeconds)
let fresh = decoded.filter { $0.date >= cutoff }.map { m -> ChatMessage in
guard m.imageJPEGBase64 != nil else { return m }
let label = m.text.isEmpty ? "[photo]" : m.text
return ChatMessage(
id: m.id, envelopeId: m.envelopeId, senderID: m.senderID, senderName: m.senderName,
text: label, date: m.date, isLocal: m.isLocal, distanceFromMe: m.distanceFromMe,
imageJPEGBase64: nil
)
}
DispatchQueue.main.async { [weak self] in
self?.chatMessages = fresh
}
}

private func saveChatHistory() {
pruneChatByTTL()
let dir = chatHistoryURL.deletingLastPathComponent()
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
// Never persist image bytes — only placeholders (text) go to disk.
let forDisk: [ChatMessage] = chatMessages.map { m in
let label: String
if m.imageJPEGBase64 != nil {
if m.text == "Photo" || m.text == "[photo]" || m.text.hasPrefix("Photo") {
label = "[photo]"
} else {
label = m.text.isEmpty ? "[photo]" : m.text
}
} else {
label = m.text
}
return ChatMessage(
id: m.id,
envelopeId: m.envelopeId,
senderID: m.senderID,
senderName: m.senderName,
text: label,
date: m.date,
isLocal: m.isLocal,
distanceFromMe: m.distanceFromMe,
imageJPEGBase64: nil
)
}
if let data = try? JSONEncoder().encode(forDisk) {
try? data.write(to: chatHistoryURL, options: .atomic)
}
}

private func startChatHistoryPruneTimer() {
DispatchQueue.main.async { [weak self] in
Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in
guard let self else { return }
self.pruneChatByTTL()
self.saveChatHistory()
}
}
}

private func requestNotificationAuthIfNeeded() {
Expand Down
Loading