mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 16:25:22 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef010dff37 | ||
|
|
8fbfa359ff | ||
|
|
eeb4d55476 | ||
|
|
4882b99752 | ||
|
|
9b8498c78e | ||
|
|
1cf8449e22 | ||
|
|
7ca1ff0a7e | ||
|
|
7316a4a46d | ||
|
|
e992d96939 | ||
|
|
e427e1c62f | ||
|
|
fae76dc515 | ||
|
|
76f976438f | ||
|
|
369c335088 | ||
|
|
a84b8c05f1 | ||
|
|
9d738cff45 | ||
|
|
9879cb2580 | ||
|
|
7040d9ecb0 | ||
|
|
88bafb41cc |
@@ -1,10 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import ImageIO
|
||||||
|
import UniformTypeIdentifiers
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
import UIKit
|
import UIKit
|
||||||
#else
|
#else
|
||||||
import AppKit
|
import AppKit
|
||||||
import ImageIO
|
|
||||||
import UniformTypeIdentifiers
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
enum ImageUtilsError: Error {
|
enum ImageUtilsError: Error {
|
||||||
@@ -40,19 +40,30 @@ enum ImageUtils {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = 512) throws -> URL {
|
static func processImage(_ image: UIImage, maxDimension: CGFloat = 512) throws -> URL {
|
||||||
return try autoreleasepool {
|
return try autoreleasepool {
|
||||||
|
// Scale the image first
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
var quality = compressionQuality
|
|
||||||
guard var jpegData = scaled.jpegData(compressionQuality: quality) else {
|
// Get CGImage from UIImage - this is the key to stripping metadata
|
||||||
|
guard let cgImage = scaled.cgImage else {
|
||||||
throw ImageUtilsError.encodingFailed
|
throw ImageUtilsError.encodingFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use CGImageDestination to encode without metadata (same as macOS)
|
||||||
|
var quality = compressionQuality
|
||||||
|
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||||
|
throw ImageUtilsError.encodingFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compress to target size
|
||||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||||
quality -= 0.1
|
quality -= 0.1
|
||||||
autoreleasepool {
|
autoreleasepool {
|
||||||
if let next = scaled.jpegData(compressionQuality: quality) {
|
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||||
jpegData = next
|
jpegData = next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let outputURL = try makeOutputURL()
|
let outputURL = try makeOutputURL()
|
||||||
try jpegData.write(to: outputURL, options: .atomic)
|
try jpegData.write(to: outputURL, options: .atomic)
|
||||||
return outputURL
|
return outputURL
|
||||||
@@ -65,12 +76,35 @@ enum ImageUtils {
|
|||||||
guard maxSide > maxDimension else { return image }
|
guard maxSide > maxDimension else { return image }
|
||||||
let scale = maxDimension / maxSide
|
let scale = maxDimension / maxSide
|
||||||
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
|
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
|
||||||
|
|
||||||
|
// Draw into a new context to get a clean CGImage without metadata
|
||||||
UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0)
|
UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0)
|
||||||
image.draw(in: CGRect(origin: .zero, size: newSize))
|
image.draw(in: CGRect(origin: .zero, size: newSize))
|
||||||
let rendered = UIGraphicsGetImageFromCurrentImageContext()
|
let rendered = UIGraphicsGetImageFromCurrentImageContext()
|
||||||
UIGraphicsEndImageContext()
|
UIGraphicsEndImageContext()
|
||||||
return rendered ?? image
|
return rendered ?? image
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
||||||
|
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||||
|
guard let data = CFDataCreateMutable(nil, 0) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
||||||
|
// By only specifying compression quality and no metadata keys,
|
||||||
|
// we ensure a clean JPEG with no privacy-leaking information
|
||||||
|
let options: [CFString: Any] = [
|
||||||
|
kCGImageDestinationLossyCompressionQuality: quality
|
||||||
|
]
|
||||||
|
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
||||||
|
guard CGImageDestinationFinalize(destination) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return data as Data
|
||||||
|
}
|
||||||
#else
|
#else
|
||||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = 512) throws -> URL {
|
static func processImage(_ image: NSImage, maxDimension: CGFloat = 512) throws -> URL {
|
||||||
return try autoreleasepool {
|
return try autoreleasepool {
|
||||||
@@ -130,6 +164,7 @@ enum ImageUtils {
|
|||||||
return scaledImage
|
return scaledImage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
||||||
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||||
guard let data = CFDataCreateMutable(nil, 0) else {
|
guard let data = CFDataCreateMutable(nil, 0) else {
|
||||||
return nil
|
return nil
|
||||||
@@ -137,8 +172,9 @@ enum ImageUtils {
|
|||||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Security H2: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
||||||
// Don't add any metadata dictionary keys - fresh CGContext ensures clean image
|
// By only specifying compression quality and no metadata keys,
|
||||||
|
// we ensure a clean JPEG with no privacy-leaking information
|
||||||
let options: [CFString: Any] = [
|
let options: [CFString: Any] = [
|
||||||
kCGImageDestinationLossyCompressionQuality: quality
|
kCGImageDestinationLossyCompressionQuality: quality
|
||||||
]
|
]
|
||||||
|
|||||||
+218
-430
@@ -182,220 +182,185 @@
|
|||||||
},
|
},
|
||||||
"shouldTranslate" : false
|
"shouldTranslate" : false
|
||||||
},
|
},
|
||||||
"%@/%@": {
|
"%@ active" : {
|
||||||
"comment": "A label showing the number of messages that have been successfully delivered, followed by a slash and the total number of messages. Both numbers are displayed in monospaced font.",
|
"comment" : "A label at the bottom of the people list sheet showing the number of active users.",
|
||||||
"isCommentAutoGenerated": true,
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "%1$@/%2$@"
|
"value" : "%@ نشطين"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bn" : {
|
"bn" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "%1$@/%2$@"
|
"value" : "%@ সক্রিয়"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "%1$@/%2$@"
|
"value" : "%@ aktiv"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "%1$@/%2$@"
|
"value" : "%@ active"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"es" : {
|
"es" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "%1$@/%2$@"
|
"value" : "%@ activos"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fil" : {
|
"fil" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "%1$@/%2$@"
|
"value" : "%@ aktibo"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fr" : {
|
"fr" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "%1$@/%2$@"
|
"value" : "%@ actifs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"he" : {
|
"he" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "%1$@/%2$@"
|
"value" : "%@ פעילים"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hi" : {
|
"hi" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "%1$@/%2$@"
|
"value" : "%@ सक्रिय"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"id" : {
|
"id" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "%1$@/%2$@"
|
"value" : "%@ aktif"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"it" : {
|
"it" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "%1$@/%2$@"
|
"value" : "%@ attivi"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ja" : {
|
"ja" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "%1$@/%2$@"
|
"value" : "%@ 人がアクティブ"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ko" : {
|
"ko" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "%1$@/%2$@"
|
"value" : "활성 사용자 %@명"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ms" : {
|
"ms" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "%1$@/%2$@"
|
"value" : "%@ aktif"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ne" : {
|
"ne" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "%1$@/%2$@"
|
"value" : "%@ सक्रिय"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nl" : {
|
"nl" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "%1$@/%2$@"
|
"value" : "%@ actief"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pl" : {
|
"pl" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "%1$@/%2$@"
|
"value" : "%@ aktywni"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pt" : {
|
"pt" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "%1$@/%2$@"
|
"value" : "%@ ativos"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pt-BR" : {
|
"pt-BR" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "%1$@/%2$@"
|
"value" : "%@ ativos"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ru" : {
|
"ru" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "%1$@/%2$@"
|
"value" : "%@ активных"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sv" : {
|
"sv" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "%1$@/%2$@"
|
"value" : "%@ aktiva"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ta" : {
|
"ta" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "%1$@/%2$@"
|
"value" : "%@ செயலில்"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"th" : {
|
"th" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "%1$@/%2$@"
|
"value" : "%@ กำลังใช้งาน"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tr" : {
|
"tr" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "%1$@/%2$@"
|
"value" : "%@ aktif"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"uk" : {
|
"uk" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "%1$@/%2$@"
|
"value" : "%@ активних"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ur" : {
|
"ur" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "%1$@/%2$@"
|
"value" : "%@ فعال"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"vi" : {
|
"vi" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "%1$@/%2$@"
|
"value" : "%@ đang hoạt động"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"zh-Hans" : {
|
"zh-Hans" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "%1$@/%2$@"
|
"value" : "活跃用户 %@ 名"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"zh-Hant" : {
|
"zh-Hant" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "%1$@/%2$@"
|
"value" : "活躍使用者 %@ 位"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"%@ active": {
|
|
||||||
"comment": "A label at the bottom of the people list sheet showing the number of active users.",
|
|
||||||
"localizations": {
|
|
||||||
"ar": { "stringUnit": { "state": "translated", "value": "%@ نشطين" } },
|
|
||||||
"bn": { "stringUnit": { "state": "translated", "value": "%@ সক্রিয়" } },
|
|
||||||
"de": { "stringUnit": { "state": "translated", "value": "%@ aktiv" } },
|
|
||||||
"en": { "stringUnit": { "state": "translated", "value": "%@ active" } },
|
|
||||||
"es": { "stringUnit": { "state": "translated", "value": "%@ activos" } },
|
|
||||||
"fil": { "stringUnit": { "state": "translated", "value": "%@ aktibo" } },
|
|
||||||
"fr": { "stringUnit": { "state": "translated", "value": "%@ actifs" } },
|
|
||||||
"he": { "stringUnit": { "state": "translated", "value": "%@ פעילים" } },
|
|
||||||
"hi": { "stringUnit": { "state": "translated", "value": "%@ सक्रिय" } },
|
|
||||||
"id": { "stringUnit": { "state": "translated", "value": "%@ aktif" } },
|
|
||||||
"it": { "stringUnit": { "state": "translated", "value": "%@ attivi" } },
|
|
||||||
"ja": { "stringUnit": { "state": "translated", "value": "%@ 人がアクティブ" } },
|
|
||||||
"ko": { "stringUnit": { "state": "translated", "value": "활성 사용자 %@명" } },
|
|
||||||
"ms": { "stringUnit": { "state": "translated", "value": "%@ aktif" } },
|
|
||||||
"ne": { "stringUnit": { "state": "translated", "value": "%@ सक्रिय" } },
|
|
||||||
"nl": { "stringUnit": { "state": "translated", "value": "%@ actief" } },
|
|
||||||
"pl": { "stringUnit": { "state": "translated", "value": "%@ aktywni" } },
|
|
||||||
"pt": { "stringUnit": { "state": "translated", "value": "%@ ativos" } },
|
|
||||||
"pt-BR": { "stringUnit": { "state": "translated", "value": "%@ ativos" } },
|
|
||||||
"ru": { "stringUnit": { "state": "translated", "value": "%@ активных" } },
|
|
||||||
"sv": { "stringUnit": { "state": "translated", "value": "%@ aktiva" } },
|
|
||||||
"ta": { "stringUnit": { "state": "translated", "value": "%@ செயலில்" } },
|
|
||||||
"th": { "stringUnit": { "state": "translated", "value": "%@ กำลังใช้งาน" } },
|
|
||||||
"tr": { "stringUnit": { "state": "translated", "value": "%@ aktif" } },
|
|
||||||
"uk": { "stringUnit": { "state": "translated", "value": "%@ активних" } },
|
|
||||||
"ur": { "stringUnit": { "state": "translated", "value": "%@ فعال" } },
|
|
||||||
"vi": { "stringUnit": { "state": "translated", "value": "%@ đang hoạt động" } },
|
|
||||||
"zh-Hans": { "stringUnit": { "state": "translated", "value": "活跃用户 %@ 名" } },
|
|
||||||
"zh-Hant": { "stringUnit": { "state": "translated", "value": "活躍使用者 %@ 位" } }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"app_info.app_name" : {
|
"app_info.app_name" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -6303,9 +6268,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Choose an image" : {
|
||||||
|
"comment" : "A label displayed above a button that allows the user to choose an image to send.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"close" : {
|
"close" : {
|
||||||
"comment" : "Button to dismiss fullscreen media viewer",
|
"comment" : "Button to dismiss fullscreen media viewer",
|
||||||
"isCommentAutoGenerated": true,
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -6436,7 +6404,7 @@
|
|||||||
"ta" : {
|
"ta" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "மூடு"
|
"value" : "மூடவும்"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"th" : {
|
"th" : {
|
||||||
@@ -30905,9 +30873,187 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"recording %@" : {
|
||||||
|
"comment" : "Voice note recording duration indicator",
|
||||||
|
"localizations" : {
|
||||||
|
"ar" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "جارٍ التسجيل %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bn" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "রেকর্ডিং %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "aufnahme %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "recording %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "grabando %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fil" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "nagre-record %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "enregistrement %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"he" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "הקלטה %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"hi" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "रिकॉर्डिंग %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "merekam %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"it" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "registrazione %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "録音中 %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ko" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "녹음 중 %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ms" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "merakam %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ne" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "रेकर्डिङ %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nl" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "opname %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pl" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "nagrywanie %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pt" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "a gravar %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pt-BR" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "gravando %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "идет запись %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sv" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "spelar in %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ta" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "பதிவு %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"th" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "กำลังบันทึก %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "kaydediliyor %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uk" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "йде запис %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ur" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "ریکارڈنگ %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vi" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "đang ghi %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "录音中 %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "錄音中 %@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"save" : {
|
"save" : {
|
||||||
"comment" : "Button to save media to device",
|
"comment" : "Button to save media to device",
|
||||||
"isCommentAutoGenerated": true,
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -30996,7 +31142,7 @@
|
|||||||
"ne" : {
|
"ne" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "बचत"
|
"value" : "सेभ"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nl" : {
|
"nl" : {
|
||||||
@@ -31014,7 +31160,7 @@
|
|||||||
"pt" : {
|
"pt" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "salvar"
|
"value" : "guardar"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pt-BR" : {
|
"pt-BR" : {
|
||||||
@@ -31038,7 +31184,7 @@
|
|||||||
"ta" : {
|
"ta" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value": "சேமி"
|
"value" : "சேமிக்கவும்"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"th" : {
|
"th" : {
|
||||||
@@ -35559,364 +35705,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"open": {
|
|
||||||
"comment": "Button to open attached file",
|
|
||||||
"localizations": {
|
|
||||||
"ar": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "فتح"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"bn": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "খুলুন"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "öffnen"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "open"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"es": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "abrir"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"fil": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "buksan"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"fr": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "ouvrir"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"he": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "פתח"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"hi": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "खोलें"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"id": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "buka"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"it": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "apri"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ja": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "開く"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ko": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "열기"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ms": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "buka"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ne": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "खोल्नुहोस्"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nl": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "openen"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pl": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "otwórz"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pt": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "abrir"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pt-BR": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "abrir"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ru": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "открыть"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"sv": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "öppna"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ta": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "திற"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"th": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "เปิด"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tr": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "aç"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"uk": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "відкрити"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ur": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "کھولیں"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"vi": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "mở"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"zh-Hans": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "打开"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"zh-Hant": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "開啟"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"recording %@": {
|
|
||||||
"comment": "Voice note recording duration indicator",
|
|
||||||
"localizations": {
|
|
||||||
"ar": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "التسجيل %@"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"bn": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "রেকর্ডিং %@"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "aufnahme %@"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "recording %@"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"es": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "grabando %@"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"fil": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "nagrerekord %@"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"fr": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "enregistrement %@"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"he": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "מקליט %@"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"hi": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "रिकॉर्डिंग %@"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"id": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "merekam %@"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"it": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "registrazione %@"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ja": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "録音中 %@"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ko": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "녹음 중 %@"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ms": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "merakam %@"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ne": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "रेकर्डिङ %@"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nl": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "opname %@"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pl": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "nagrywanie %@"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pt": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "gravando %@"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pt-BR": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "gravando %@"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ru": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "запись %@"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"sv": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "spelar in %@"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ta": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "பதிவு செய்கிறது %@"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"th": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "กำลังบันทึก %@"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tr": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "kaydediliyor %@"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"uk": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "запис %@"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ur": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "ریکارڈنگ %@"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"vi": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "đang ghi %@"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"zh-Hans": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "录音中 %@"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"zh-Hant": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "錄音中 %@"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version" : "1.1"
|
"version" : "1.1"
|
||||||
|
|||||||
@@ -906,6 +906,16 @@ struct NostrFilter: Encodable {
|
|||||||
filter.limit = limit
|
filter.limit = limit
|
||||||
return filter
|
return filter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For location notes with neighbors: subscribe to multiple geohashes (center + neighbors)
|
||||||
|
static func geohashNotes(_ geohashes: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter {
|
||||||
|
var filter = NostrFilter()
|
||||||
|
filter.kinds = [1]
|
||||||
|
filter.since = since?.timeIntervalSince1970.toInt()
|
||||||
|
filter.tagFilters = ["g": geohashes]
|
||||||
|
filter.limit = limit
|
||||||
|
return filter
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dynamic coding key for tag filters
|
// Dynamic coding key for tag filters
|
||||||
|
|||||||
@@ -333,7 +333,8 @@ struct BinaryProtocol {
|
|||||||
originalSize = Int(rawSize)
|
originalSize = Int(rawSize)
|
||||||
}
|
}
|
||||||
// Guard to keep decompression bounded to sane BLE payload limits
|
// Guard to keep decompression bounded to sane BLE payload limits
|
||||||
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxPayloadBytes else { return nil }
|
// Use maxFramedFileBytes to account for TLV overhead in file transfer payloads
|
||||||
|
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxFramedFileBytes else { return nil }
|
||||||
let compressedSize = payloadLength - lengthFieldBytes
|
let compressedSize = payloadLength - lengthFieldBytes
|
||||||
guard compressedSize >= 0, let compressed = readData(compressedSize) else { return nil }
|
guard compressedSize >= 0, let compressed = readData(compressedSize) else { return nil }
|
||||||
|
|
||||||
|
|||||||
@@ -119,4 +119,57 @@ enum Geohash {
|
|||||||
}
|
}
|
||||||
return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1)
|
return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns all 8 neighboring geohash cells at the same precision.
|
||||||
|
/// - Parameter geohash: Base32 geohash string.
|
||||||
|
/// - Returns: Array of 8 neighboring geohashes (N, NE, E, SE, S, SW, W, NW order).
|
||||||
|
static func neighbors(of geohash: String) -> [String] {
|
||||||
|
guard !geohash.isEmpty else { return [] }
|
||||||
|
|
||||||
|
let precision = geohash.count
|
||||||
|
let bounds = decodeBounds(geohash)
|
||||||
|
let center = decodeCenter(geohash)
|
||||||
|
|
||||||
|
// Calculate cell dimensions
|
||||||
|
let latHeight = bounds.latMax - bounds.latMin
|
||||||
|
let lonWidth = bounds.lonMax - bounds.lonMin
|
||||||
|
|
||||||
|
// Helper to wrap longitude around ±180
|
||||||
|
func wrapLongitude(_ lon: Double) -> Double {
|
||||||
|
var wrapped = lon
|
||||||
|
while wrapped > 180.0 { wrapped -= 360.0 }
|
||||||
|
while wrapped < -180.0 { wrapped += 360.0 }
|
||||||
|
return wrapped
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to clamp latitude to ±90
|
||||||
|
func clampLatitude(_ lat: Double) -> Double {
|
||||||
|
return max(-90.0, min(90.0, lat))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate 8 neighbor centers
|
||||||
|
let neighbors: [(lat: Double, lon: Double)] = [
|
||||||
|
(center.lat + latHeight, center.lon), // N
|
||||||
|
(center.lat + latHeight, center.lon + lonWidth), // NE
|
||||||
|
(center.lat, center.lon + lonWidth), // E
|
||||||
|
(center.lat - latHeight, center.lon + lonWidth), // SE
|
||||||
|
(center.lat - latHeight, center.lon), // S
|
||||||
|
(center.lat - latHeight, center.lon - lonWidth), // SW
|
||||||
|
(center.lat, center.lon - lonWidth), // W
|
||||||
|
(center.lat + latHeight, center.lon - lonWidth) // NW
|
||||||
|
]
|
||||||
|
|
||||||
|
// Encode each neighbor, handling boundary conditions
|
||||||
|
return neighbors.compactMap { neighbor in
|
||||||
|
let lat = clampLatitude(neighbor.lat)
|
||||||
|
let lon = wrapLongitude(neighbor.lon)
|
||||||
|
|
||||||
|
// Skip if we've crossed a pole (latitude clamped to boundary)
|
||||||
|
if (neighbor.lat > 90.0 || neighbor.lat < -90.0) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return encode(latitude: lat, longitude: lon, precision: precision)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3414,11 +3414,17 @@ extension BLEService {
|
|||||||
let originalType = packet.payload[12]
|
let originalType = packet.payload[12]
|
||||||
let fragmentData = packet.payload.suffix(from: 13)
|
let fragmentData = packet.payload.suffix(from: 13)
|
||||||
|
|
||||||
// Sanity checks
|
// Sanity checks - add reasonable upper bound on total to prevent DoS
|
||||||
guard total > 0 && index >= 0 && index < total else { return }
|
guard total > 0 && total <= 10000 && index >= 0 && index < total else { return }
|
||||||
|
|
||||||
// Store fragment
|
// Compute fragment key for this assembly
|
||||||
let key = FragmentKey(sender: senderU64, id: fragU64)
|
let key = FragmentKey(sender: senderU64, id: fragU64)
|
||||||
|
|
||||||
|
// Critical section: Store fragment and check completion status
|
||||||
|
var shouldReassemble: Bool = false
|
||||||
|
var fragmentsToReassemble: [Int: Data]? = nil
|
||||||
|
|
||||||
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
if incomingFragments[key] == nil {
|
if incomingFragments[key] == nil {
|
||||||
// Cap in-flight assemblies to prevent memory/battery blowups
|
// Cap in-flight assemblies to prevent memory/battery blowups
|
||||||
if incomingFragments.count >= maxInFlightAssemblies {
|
if incomingFragments.count >= maxInFlightAssemblies {
|
||||||
@@ -3431,12 +3437,45 @@ extension BLEService {
|
|||||||
incomingFragments[key] = [:]
|
incomingFragments[key] = [:]
|
||||||
fragmentMetadata[key] = (originalType, total, Date())
|
fragmentMetadata[key] = (originalType, total, Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check cumulative size before storing this fragment
|
||||||
|
let currentSize = incomingFragments[key]?.values.reduce(0) { $0 + $1.count } ?? 0
|
||||||
|
let assemblyLimit: Int = {
|
||||||
|
if originalType == MessageType.fileTransfer.rawValue {
|
||||||
|
// Allow headroom for TLV metadata and binary framing overhead.
|
||||||
|
return FileTransferLimits.maxFramedFileBytes
|
||||||
|
}
|
||||||
|
return FileTransferLimits.maxPayloadBytes
|
||||||
|
}()
|
||||||
|
let projectedSize = currentSize + fragmentData.count
|
||||||
|
guard projectedSize <= assemblyLimit else {
|
||||||
|
// Exceeds size limit - evict this assembly
|
||||||
|
SecureLogger.warning(
|
||||||
|
"🚫 Fragment assembly exceeds size limit (\(projectedSize) bytes > \(assemblyLimit)), evicting. Type=\(originalType) Index=\(index)/\(total)",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
incomingFragments.removeValue(forKey: key)
|
||||||
|
fragmentMetadata.removeValue(forKey: key)
|
||||||
|
shouldReassemble = false
|
||||||
|
fragmentsToReassemble = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
incomingFragments[key]?[index] = Data(fragmentData)
|
incomingFragments[key]?[index] = Data(fragmentData)
|
||||||
|
|
||||||
// Check if complete
|
// Check if complete
|
||||||
if let fragments = incomingFragments[key],
|
if let fragments = incomingFragments[key], fragments.count == total {
|
||||||
fragments.count == total {
|
shouldReassemble = true
|
||||||
// Reassemble
|
fragmentsToReassemble = fragments
|
||||||
|
} else {
|
||||||
|
shouldReassemble = false
|
||||||
|
fragmentsToReassemble = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heavy work outside lock: reassemble and decode
|
||||||
|
guard shouldReassemble, let fragments = fragmentsToReassemble else { return }
|
||||||
|
|
||||||
var reassembled = Data()
|
var reassembled = Data()
|
||||||
for i in 0..<total {
|
for i in 0..<total {
|
||||||
if let fragment = fragments[i] {
|
if let fragment = fragments[i] {
|
||||||
@@ -3451,7 +3490,8 @@ extension BLEService {
|
|||||||
SecureLogger.error("❌ Failed to decode reassembled packet (type=\(originalType), total=\(total))", category: .session)
|
SecureLogger.error("❌ Failed to decode reassembled packet (type=\(originalType), total=\(total))", category: .session)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup
|
// Critical section: Cleanup completed assembly
|
||||||
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
incomingFragments.removeValue(forKey: key)
|
incomingFragments.removeValue(forKey: key)
|
||||||
fragmentMetadata.removeValue(forKey: key)
|
fragmentMetadata.removeValue(forKey: key)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import BitLogger
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct LocationNotesCounterDependencies {
|
|
||||||
typealias RelayLookup = @MainActor (_ geohash: String, _ count: Int) -> [String]
|
|
||||||
typealias Subscribe = @MainActor (_ filter: NostrFilter, _ id: String, _ relays: [String], _ handler: @escaping (NostrEvent) -> Void, _ onEOSE: (() -> Void)?) -> Void
|
|
||||||
typealias Unsubscribe = @MainActor (_ id: String) -> Void
|
|
||||||
|
|
||||||
var relayLookup: RelayLookup
|
|
||||||
var subscribe: Subscribe
|
|
||||||
var unsubscribe: Unsubscribe
|
|
||||||
|
|
||||||
static let live = LocationNotesCounterDependencies(
|
|
||||||
relayLookup: { geohash, count in
|
|
||||||
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
|
|
||||||
},
|
|
||||||
subscribe: { filter, id, relays, handler, onEOSE in
|
|
||||||
NostrRelayManager.shared.subscribe(
|
|
||||||
filter: filter,
|
|
||||||
id: id,
|
|
||||||
relayUrls: relays,
|
|
||||||
handler: handler,
|
|
||||||
onEOSE: onEOSE
|
|
||||||
)
|
|
||||||
},
|
|
||||||
unsubscribe: { id in
|
|
||||||
NostrRelayManager.shared.unsubscribe(id: id)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lightweight background counter for location notes (kind 1) at building-level geohash (8 chars).
|
|
||||||
@MainActor
|
|
||||||
final class LocationNotesCounter: ObservableObject {
|
|
||||||
static let shared = LocationNotesCounter()
|
|
||||||
|
|
||||||
@Published private(set) var geohash: String? = nil
|
|
||||||
@Published private(set) var count: Int? = 0
|
|
||||||
@Published private(set) var initialLoadComplete: Bool = false
|
|
||||||
@Published private(set) var relayAvailable: Bool = true
|
|
||||||
|
|
||||||
private var subscriptionID: String? = nil
|
|
||||||
private var noteIDs = Set<String>()
|
|
||||||
private let dependencies: LocationNotesCounterDependencies
|
|
||||||
|
|
||||||
private init(dependencies: LocationNotesCounterDependencies = .live) {
|
|
||||||
self.dependencies = dependencies
|
|
||||||
}
|
|
||||||
|
|
||||||
init(testDependencies: LocationNotesCounterDependencies) {
|
|
||||||
self.dependencies = testDependencies
|
|
||||||
}
|
|
||||||
|
|
||||||
func subscribe(geohash gh: String) {
|
|
||||||
let norm = gh.lowercased()
|
|
||||||
if geohash == norm, subscriptionID != nil { return }
|
|
||||||
// Validate geohash (building-level precision: 8 chars)
|
|
||||||
guard Geohash.isValidBuildingGeohash(norm) else {
|
|
||||||
SecureLogger.warning("LocationNotesCounter: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Unsubscribe previous without clearing count to avoid flicker
|
|
||||||
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
|
||||||
subscriptionID = nil
|
|
||||||
geohash = norm
|
|
||||||
noteIDs.removeAll()
|
|
||||||
initialLoadComplete = false
|
|
||||||
relayAvailable = true
|
|
||||||
|
|
||||||
// Subscribe only to the building geohash (precision 8)
|
|
||||||
let subID = "locnotes-count-\(norm)-\(UUID().uuidString.prefix(6))"
|
|
||||||
let relays = dependencies.relayLookup(norm, TransportConfig.nostrGeoRelayCount)
|
|
||||||
guard !relays.isEmpty else {
|
|
||||||
relayAvailable = false
|
|
||||||
initialLoadComplete = true
|
|
||||||
count = 0
|
|
||||||
SecureLogger.warning("LocationNotesCounter: no geo relays for geohash=\(norm)", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
subscriptionID = subID
|
|
||||||
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 200)
|
|
||||||
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
|
||||||
guard let self = self else { return }
|
|
||||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
|
||||||
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == norm }) else { return }
|
|
||||||
if !self.noteIDs.contains(event.id) {
|
|
||||||
self.noteIDs.insert(event.id)
|
|
||||||
self.count = self.noteIDs.count
|
|
||||||
}
|
|
||||||
}, { [weak self] in
|
|
||||||
self?.initialLoadComplete = true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func cancel() {
|
|
||||||
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
|
||||||
subscriptionID = nil
|
|
||||||
geohash = nil
|
|
||||||
count = 0
|
|
||||||
noteIDs.removeAll()
|
|
||||||
relayAvailable = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -163,14 +163,22 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
|
|
||||||
subscriptionID = subID
|
subscriptionID = subID
|
||||||
initialLoadComplete = false
|
initialLoadComplete = false
|
||||||
// For persistent notes, allow relays to return recent history without an aggressive time cutoff
|
|
||||||
let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200)
|
// Subscribe to center + 8 neighbors (± 1 grid)
|
||||||
|
let neighbors = Geohash.neighbors(of: geohash)
|
||||||
|
let allGeohashes = [geohash] + neighbors
|
||||||
|
let filter = NostrFilter.geohashNotes(allGeohashes, since: nil, limit: 200)
|
||||||
|
|
||||||
|
// Build a set of valid geohashes for tag matching (includes all 9 cells)
|
||||||
|
let validGeohashes = Set(allGeohashes.map { $0.lowercased() })
|
||||||
|
|
||||||
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||||
// Ensure matching tag
|
// Ensure matching tag - accept any of our 9 geohashes
|
||||||
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == self.geohash }) else { return }
|
guard event.tags.contains(where: { tag in
|
||||||
|
tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased())
|
||||||
|
}) else { return }
|
||||||
guard !self.noteIDs.contains(event.id) else { return }
|
guard !self.noteIDs.contains(event.id) else { return }
|
||||||
self.noteIDs.insert(event.id)
|
self.noteIDs.insert(event.id)
|
||||||
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
|
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
|
||||||
|
|||||||
@@ -2503,65 +2503,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func sendFileAttachment(from sourceURL: URL) {
|
|
||||||
let targetPeer = selectedPrivateChatPeer
|
|
||||||
|
|
||||||
Task.detached(priority: .userInitiated) { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
defer { try? FileManager.default.removeItem(at: sourceURL) }
|
|
||||||
var destinationURL: URL?
|
|
||||||
do {
|
|
||||||
// Security H1: Check file size BEFORE reading into memory
|
|
||||||
let attrs = try FileManager.default.attributesOfItem(atPath: sourceURL.path)
|
|
||||||
guard let fileSize = attrs[.size] as? Int else {
|
|
||||||
throw MediaSendError.encodingFailed
|
|
||||||
}
|
|
||||||
guard FileTransferLimits.isValidPayload(fileSize) else {
|
|
||||||
throw MediaSendError.tooLarge
|
|
||||||
}
|
|
||||||
|
|
||||||
let data = try Data(contentsOf: sourceURL)
|
|
||||||
|
|
||||||
let destination = try self.prepareOutgoingFileCopy(from: sourceURL, data: data)
|
|
||||||
destinationURL = destination
|
|
||||||
let packet = BitchatFilePacket(
|
|
||||||
fileName: destination.lastPathComponent,
|
|
||||||
fileSize: UInt64(data.count),
|
|
||||||
mimeType: self.mimeType(for: destination),
|
|
||||||
content: data
|
|
||||||
)
|
|
||||||
guard packet.encode() != nil else {
|
|
||||||
try? FileManager.default.removeItem(at: destination)
|
|
||||||
throw MediaSendError.encodingFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
await MainActor.run {
|
|
||||||
let message = self.enqueueMediaMessage(content: "[file] \(destination.lastPathComponent)", targetPeer: targetPeer?.id)
|
|
||||||
let messageID = message.id
|
|
||||||
let transferId = self.makeTransferID(messageID: messageID)
|
|
||||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
|
||||||
if let peerID = targetPeer {
|
|
||||||
self.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
|
||||||
} else {
|
|
||||||
self.meshService.sendFileBroadcast(packet, transferId: transferId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch MediaSendError.tooLarge {
|
|
||||||
await MainActor.run {
|
|
||||||
self.addSystemMessage("File is too large to send.")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if let destination = destinationURL {
|
|
||||||
try? FileManager.default.removeItem(at: destination)
|
|
||||||
}
|
|
||||||
SecureLogger.error("File attachment send failed: \(error)", category: .session)
|
|
||||||
await MainActor.run {
|
|
||||||
self.addSystemMessage("Failed to send file.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func cancelMediaSend(messageID: String) {
|
func cancelMediaSend(messageID: String) {
|
||||||
@@ -2748,55 +2689,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func prepareOutgoingFileCopy(from sourceURL: URL, data: Data) throws -> URL {
|
|
||||||
let base = try applicationFilesDirectory().appendingPathComponent("files/outgoing", isDirectory: true)
|
|
||||||
try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
|
|
||||||
|
|
||||||
let rawName = sourceURL.lastPathComponent
|
|
||||||
let sanitized = sanitizeOutgoingFileName(rawName.isEmpty ? "file" : rawName)
|
|
||||||
var destination = base.appendingPathComponent(sanitized)
|
|
||||||
var counter = 1
|
|
||||||
while FileManager.default.fileExists(atPath: destination.path) {
|
|
||||||
let baseName = (sanitized as NSString).deletingPathExtension
|
|
||||||
let ext = (sanitized as NSString).pathExtension
|
|
||||||
let newName: String
|
|
||||||
if ext.isEmpty {
|
|
||||||
newName = "\(baseName) (\(counter))"
|
|
||||||
} else {
|
|
||||||
newName = "\(baseName) (\(counter)).\(ext)"
|
|
||||||
}
|
|
||||||
destination = base.appendingPathComponent(newName)
|
|
||||||
counter += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
try data.write(to: destination, options: .atomic)
|
|
||||||
return destination
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sanitizeOutgoingFileName(_ name: String) -> String {
|
|
||||||
var candidate = name
|
|
||||||
candidate = candidate.replacingOccurrences(of: "\\", with: "/")
|
|
||||||
candidate = candidate.components(separatedBy: "/").last ?? name
|
|
||||||
candidate = candidate.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if candidate.isEmpty { candidate = "file" }
|
|
||||||
let invalid = CharacterSet(charactersIn: "<>:\"|?*")
|
|
||||||
candidate = candidate.components(separatedBy: invalid).joined(separator: "_")
|
|
||||||
if candidate.isEmpty { candidate = "file" }
|
|
||||||
if (candidate as NSString).pathExtension.isEmpty {
|
|
||||||
candidate += ".bin"
|
|
||||||
}
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
|
|
||||||
private func mimeType(for url: URL) -> String {
|
|
||||||
let ext = url.pathExtension.lowercased()
|
|
||||||
if !ext.isEmpty,
|
|
||||||
let type = UTType(filenameExtension: ext),
|
|
||||||
let mime = type.preferredMIMEType {
|
|
||||||
return mime
|
|
||||||
}
|
|
||||||
return "application/octet-stream"
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applicationFilesDirectory() throws -> URL {
|
private func applicationFilesDirectory() throws -> URL {
|
||||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
|||||||
+151
-119
@@ -15,9 +15,6 @@ import AppKit
|
|||||||
#endif
|
#endif
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
import BitLogger
|
import BitLogger
|
||||||
#if canImport(PhotosUI)
|
|
||||||
import PhotosUI
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// MARK: - Supporting Types
|
// MARK: - Supporting Types
|
||||||
|
|
||||||
@@ -38,7 +35,6 @@ struct ContentView: View {
|
|||||||
@EnvironmentObject var viewModel: ChatViewModel
|
@EnvironmentObject var viewModel: ChatViewModel
|
||||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
@ObservedObject private var locationManager = LocationChannelManager.shared
|
||||||
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
||||||
@ObservedObject private var notesCounter = LocationNotesCounter.shared
|
|
||||||
@State private var messageText = ""
|
@State private var messageText = ""
|
||||||
@FocusState private var isTextFieldFocused: Bool
|
@FocusState private var isTextFieldFocused: Bool
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@@ -72,12 +68,12 @@ struct ContentView: View {
|
|||||||
@State private var recordingDuration: TimeInterval = 0
|
@State private var recordingDuration: TimeInterval = 0
|
||||||
@State private var recordingTimer: Timer?
|
@State private var recordingTimer: Timer?
|
||||||
@State private var recordingStartDate: Date?
|
@State private var recordingStartDate: Date?
|
||||||
@State private var showFileImporter = false
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@State private var showPhotoPicker = false
|
@State private var showImagePicker = false
|
||||||
@State private var selectedPhotoPickerItem: PhotosPickerItem?
|
@State private var imagePickerSourceType: UIImagePickerController.SourceType = .camera
|
||||||
|
#else
|
||||||
|
@State private var showMacImagePicker = false
|
||||||
#endif
|
#endif
|
||||||
@State private var showAttachmentActions = false
|
|
||||||
@ScaledMetric(relativeTo: .body) private var headerHeight: CGFloat = 44
|
@ScaledMetric(relativeTo: .body) private var headerHeight: CGFloat = 44
|
||||||
@ScaledMetric(relativeTo: .subheadline) private var headerPeerIconSize: CGFloat = 11
|
@ScaledMetric(relativeTo: .subheadline) private var headerPeerIconSize: CGFloat = 11
|
||||||
@ScaledMetric(relativeTo: .subheadline) private var headerPeerCountFontSize: CGFloat = 12
|
@ScaledMetric(relativeTo: .subheadline) private var headerPeerCountFontSize: CGFloat = 12
|
||||||
@@ -211,15 +207,46 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoPickerItem, matching: .images)
|
.sheet(isPresented: $showImagePicker) {
|
||||||
.onChange(of: selectedPhotoPickerItem) { newItem in
|
ImagePickerView(sourceType: imagePickerSourceType) { image in
|
||||||
guard let item = newItem else { return }
|
showImagePicker = false
|
||||||
Task { await handlePhotoSelection(item) }
|
if let image = image {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let processedURL = try ImageUtils.processImage(image)
|
||||||
|
await MainActor.run {
|
||||||
|
viewModel.sendImage(from: processedURL)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.large])
|
||||||
|
.presentationDragIndicator(.hidden)
|
||||||
|
.ignoresSafeArea()
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
.fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.data], allowsMultipleSelection: false) { result in
|
#if os(macOS)
|
||||||
handleImportResult(result, handler: handleImportedFile)
|
.sheet(isPresented: $showMacImagePicker) {
|
||||||
|
MacImagePickerView { url in
|
||||||
|
showMacImagePicker = false
|
||||||
|
if let url = url {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let processedURL = try ImageUtils.processImage(at: url)
|
||||||
|
await MainActor.run {
|
||||||
|
viewModel.sendImage(from: processedURL)
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
.sheet(isPresented: Binding(
|
.sheet(isPresented: Binding(
|
||||||
get: { imagePreviewURL != nil },
|
get: { imagePreviewURL != nil },
|
||||||
set: { presenting in if !presenting { imagePreviewURL = nil } }
|
set: { presenting in if !presenting { imagePreviewURL = nil } }
|
||||||
@@ -228,19 +255,6 @@ struct ContentView: View {
|
|||||||
ImagePreviewView(url: url)
|
ImagePreviewView(url: url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.confirmationDialog("Attach", isPresented: $showAttachmentActions, titleVisibility: .visible) {
|
|
||||||
#if os(iOS)
|
|
||||||
Button("Image") {
|
|
||||||
showAttachmentActions = false
|
|
||||||
DispatchQueue.main.async { showPhotoPicker = true }
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
Button("File") {
|
|
||||||
showAttachmentActions = false
|
|
||||||
DispatchQueue.main.async { showFileImporter = true }
|
|
||||||
}
|
|
||||||
Button("Cancel", role: .cancel) {}
|
|
||||||
}
|
|
||||||
.alert("Recording Error", isPresented: $showRecordingAlert, actions: {
|
.alert("Recording Error", isPresented: $showRecordingAlert, actions: {
|
||||||
Button("OK", role: .cancel) {}
|
Button("OK", role: .cancel) {}
|
||||||
}, message: {
|
}, message: {
|
||||||
@@ -1349,10 +1363,9 @@ struct ContentView: View {
|
|||||||
showLocationNotes = true
|
showLocationNotes = true
|
||||||
}) {
|
}) {
|
||||||
HStack(alignment: .center, spacing: 4) {
|
HStack(alignment: .center, spacing: 4) {
|
||||||
let hasNotes = (notesCounter.count ?? 0) > 0
|
Image(systemName: "note.text")
|
||||||
Image(systemName: "long.text.page.and.pencil")
|
|
||||||
.font(.bitchatSystem(size: 12))
|
.font(.bitchatSystem(size: 12))
|
||||||
.foregroundColor(hasNotes ? textColor : Color.gray)
|
.foregroundColor(Color.orange.opacity(0.8))
|
||||||
.padding(.top, 1)
|
.padding(.top, 1)
|
||||||
}
|
}
|
||||||
.fixedSize(horizontal: true, vertical: false)
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
@@ -1454,7 +1467,7 @@ struct ContentView: View {
|
|||||||
}) {
|
}) {
|
||||||
Group {
|
Group {
|
||||||
if let gh = notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash {
|
if let gh = notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash {
|
||||||
LocationNotesView(geohash: gh, onNotesCountChanged: { cnt in sheetNotesCount = cnt })
|
LocationNotesView(geohash: gh)
|
||||||
.environmentObject(viewModel)
|
.environmentObject(viewModel)
|
||||||
} else {
|
} else {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
@@ -1511,7 +1524,6 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
updateNotesCounterSubscription()
|
|
||||||
if case .mesh = locationManager.selectedChannel,
|
if case .mesh = locationManager.selectedChannel,
|
||||||
locationManager.permissionState == .authorized,
|
locationManager.permissionState == .authorized,
|
||||||
LocationChannelManager.shared.availableChannels.isEmpty {
|
LocationChannelManager.shared.availableChannels.isEmpty {
|
||||||
@@ -1519,16 +1531,13 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: locationManager.selectedChannel) { _ in
|
.onChange(of: locationManager.selectedChannel) { _ in
|
||||||
updateNotesCounterSubscription()
|
|
||||||
if case .mesh = locationManager.selectedChannel,
|
if case .mesh = locationManager.selectedChannel,
|
||||||
locationManager.permissionState == .authorized,
|
locationManager.permissionState == .authorized,
|
||||||
LocationChannelManager.shared.availableChannels.isEmpty {
|
LocationChannelManager.shared.availableChannels.isEmpty {
|
||||||
LocationChannelManager.shared.refreshChannels()
|
LocationChannelManager.shared.refreshChannels()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: locationManager.availableChannels) { _ in updateNotesCounterSubscription() }
|
|
||||||
.onChange(of: locationManager.permissionState) { _ in
|
.onChange(of: locationManager.permissionState) { _ in
|
||||||
updateNotesCounterSubscription()
|
|
||||||
if case .mesh = locationManager.selectedChannel,
|
if case .mesh = locationManager.selectedChannel,
|
||||||
locationManager.permissionState == .authorized,
|
locationManager.permissionState == .authorized,
|
||||||
LocationChannelManager.shared.availableChannels.isEmpty {
|
LocationChannelManager.shared.availableChannels.isEmpty {
|
||||||
@@ -1545,34 +1554,6 @@ struct ContentView: View {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Notes Counter Subscription Helper
|
|
||||||
extension ContentView {
|
|
||||||
private func updateNotesCounterSubscription() {
|
|
||||||
switch locationManager.selectedChannel {
|
|
||||||
case .mesh:
|
|
||||||
// Ensure we have a fresh one-shot location fix so building geohash is current
|
|
||||||
if locationManager.permissionState == .authorized {
|
|
||||||
LocationChannelManager.shared.refreshChannels()
|
|
||||||
}
|
|
||||||
if locationManager.permissionState == .authorized {
|
|
||||||
if let building = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash {
|
|
||||||
LocationNotesCounter.shared.subscribe(geohash: building)
|
|
||||||
} else {
|
|
||||||
// Keep existing subscription if we had one to avoid flicker
|
|
||||||
// Only cancel if we have no known geohash
|
|
||||||
if LocationNotesCounter.shared.geohash == nil {
|
|
||||||
LocationNotesCounter.shared.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LocationNotesCounter.shared.cancel()
|
|
||||||
}
|
|
||||||
case .location:
|
|
||||||
LocationNotesCounter.shared.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helper Views
|
// MARK: - Helper Views
|
||||||
|
|
||||||
// Rounded payment chip button
|
// Rounded payment chip button
|
||||||
@@ -1581,11 +1562,10 @@ extension ContentView {
|
|||||||
private enum MessageMedia {
|
private enum MessageMedia {
|
||||||
case voice(URL)
|
case voice(URL)
|
||||||
case image(URL)
|
case image(URL)
|
||||||
case file(URL)
|
|
||||||
|
|
||||||
var url: URL {
|
var url: URL {
|
||||||
switch self {
|
switch self {
|
||||||
case .voice(let url), .image(let url), .file(let url):
|
case .voice(let url), .image(let url):
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1623,13 +1603,6 @@ private extension ContentView {
|
|||||||
let url = baseDirectory.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(filename)
|
let url = baseDirectory.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(filename)
|
||||||
return .image(url)
|
return .image(url)
|
||||||
}
|
}
|
||||||
if message.content.hasPrefix("[file] ") {
|
|
||||||
let filename = String(message.content.dropFirst("[file] ".count)).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !filename.isEmpty else { return nil }
|
|
||||||
let subdir = message.sender == viewModel.nickname ? "files/outgoing" : "files/incoming"
|
|
||||||
let url = baseDirectory.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(filename)
|
|
||||||
return .file(url)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1721,13 +1694,6 @@ private extension ContentView {
|
|||||||
} : nil
|
} : nil
|
||||||
)
|
)
|
||||||
.frame(maxWidth: 280)
|
.frame(maxWidth: 280)
|
||||||
case .file(let url):
|
|
||||||
FileAttachmentView(
|
|
||||||
url: url,
|
|
||||||
isSending: state.isSending,
|
|
||||||
progress: state.progress,
|
|
||||||
onCancel: cancelAction
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1862,13 +1828,33 @@ private extension ContentView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var attachmentButton: some View {
|
var attachmentButton: some View {
|
||||||
Button(action: { showAttachmentActions = true }) {
|
#if os(iOS)
|
||||||
Image(systemName: "paperclip.circle.fill")
|
Image(systemName: "camera.circle.fill")
|
||||||
|
.font(.bitchatSystem(size: 24))
|
||||||
|
.foregroundColor(composerAccentColor)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.contentShape(Circle())
|
||||||
|
.onTapGesture {
|
||||||
|
// Tap = Photo Library
|
||||||
|
imagePickerSourceType = .photoLibrary
|
||||||
|
showImagePicker = true
|
||||||
|
}
|
||||||
|
.onLongPressGesture(minimumDuration: 0.3) {
|
||||||
|
// Long press = Camera
|
||||||
|
imagePickerSourceType = .camera
|
||||||
|
showImagePicker = true
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Tap for library, long press for camera")
|
||||||
|
#else
|
||||||
|
Button(action: { showMacImagePicker = true }) {
|
||||||
|
Image(systemName: "photo.circle.fill")
|
||||||
.font(.bitchatSystem(size: 24))
|
.font(.bitchatSystem(size: 24))
|
||||||
.foregroundColor(composerAccentColor)
|
.foregroundColor(composerAccentColor)
|
||||||
.frame(width: 36, height: 36)
|
.frame(width: 36, height: 36)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("Choose photo")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
var sendOrMicButton: some View {
|
var sendOrMicButton: some View {
|
||||||
@@ -2037,44 +2023,6 @@ private extension ContentView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
func handlePhotoSelection(_ item: PhotosPickerItem) async {
|
|
||||||
defer { Task { @MainActor in selectedPhotoPickerItem = nil } }
|
|
||||||
do {
|
|
||||||
if let data = try await item.loadTransferable(type: Data.self) {
|
|
||||||
let tempURL = FileManager.default.temporaryDirectory
|
|
||||||
.appendingPathComponent(UUID().uuidString)
|
|
||||||
.appendingPathExtension("jpg")
|
|
||||||
try data.write(to: tempURL, options: Data.WritingOptions.atomic)
|
|
||||||
await MainActor.run {
|
|
||||||
viewModel.sendImage(from: tempURL) {
|
|
||||||
try? FileManager.default.removeItem(at: tempURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Photo picker load failed: \(error)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
func handleImportedFile(url: URL) async {
|
|
||||||
do {
|
|
||||||
let fileManager = FileManager.default
|
|
||||||
let tempDir = fileManager.temporaryDirectory
|
|
||||||
let fileName = url.lastPathComponent.isEmpty ? "attachment" : url.lastPathComponent
|
|
||||||
let destination = tempDir.appendingPathComponent(UUID().uuidString + "_" + fileName)
|
|
||||||
if fileManager.fileExists(atPath: destination.path) {
|
|
||||||
try fileManager.removeItem(at: destination)
|
|
||||||
}
|
|
||||||
try fileManager.copyItem(at: url, to: destination)
|
|
||||||
await MainActor.run {
|
|
||||||
viewModel.sendFileAttachment(from: destination)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("File copy failed before send: \(error)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func applicationFilesDirectory() -> URL? {
|
func applicationFilesDirectory() -> URL? {
|
||||||
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
|
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
|
||||||
@@ -2218,3 +2166,87 @@ struct ImagePreviewView: View {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
// MARK: - Image Picker (Camera or Photo Library)
|
||||||
|
struct ImagePickerView: UIViewControllerRepresentable {
|
||||||
|
let sourceType: UIImagePickerController.SourceType
|
||||||
|
let completion: (UIImage?) -> Void
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIImagePickerController {
|
||||||
|
let picker = UIImagePickerController()
|
||||||
|
picker.sourceType = sourceType
|
||||||
|
picker.delegate = context.coordinator
|
||||||
|
picker.allowsEditing = false
|
||||||
|
|
||||||
|
// Use standard full screen - iOS handles safe areas automatically
|
||||||
|
picker.modalPresentationStyle = .fullScreen
|
||||||
|
|
||||||
|
// Force dark mode to make safe area bars black instead of white
|
||||||
|
picker.overrideUserInterfaceStyle = .dark
|
||||||
|
|
||||||
|
return picker
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(completion: completion)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||||
|
let completion: (UIImage?) -> Void
|
||||||
|
|
||||||
|
init(completion: @escaping (UIImage?) -> Void) {
|
||||||
|
self.completion = completion
|
||||||
|
}
|
||||||
|
|
||||||
|
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
||||||
|
let image = info[.originalImage] as? UIImage
|
||||||
|
completion(image)
|
||||||
|
}
|
||||||
|
|
||||||
|
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||||
|
completion(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
// MARK: - macOS Image Picker
|
||||||
|
struct MacImagePickerView: View {
|
||||||
|
let completion: (URL?) -> Void
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Text("Choose an image")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Button("Select Image") {
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.allowsMultipleSelection = false
|
||||||
|
panel.canChooseDirectories = false
|
||||||
|
panel.canChooseFiles = true
|
||||||
|
panel.allowedContentTypes = [.image, .png, .jpeg, .heic]
|
||||||
|
panel.message = "Choose an image to send"
|
||||||
|
|
||||||
|
if panel.runModal() == .OK {
|
||||||
|
completion(panel.url)
|
||||||
|
} else {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
|
||||||
|
Button("Cancel") {
|
||||||
|
completion(nil)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
.padding(40)
|
||||||
|
.frame(minWidth: 300, minHeight: 150)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ struct LocationNotesView: View {
|
|||||||
String(
|
String(
|
||||||
format: String(localized: "location_notes.header", comment: "Header displaying the geohash and localized note count"),
|
format: String(localized: "location_notes.header", comment: "Header displaying the geohash and localized note count"),
|
||||||
locale: .current,
|
locale: .current,
|
||||||
geohash, count
|
"\(geohash) ± 1", count
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct FileAttachmentView: View {
|
|
||||||
private let url: URL
|
|
||||||
private let isSending: Bool
|
|
||||||
private let progress: Double?
|
|
||||||
private let onCancel: (() -> Void)?
|
|
||||||
|
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
|
||||||
#if os(iOS)
|
|
||||||
@State private var showExporter = false
|
|
||||||
#endif
|
|
||||||
|
|
||||||
init(url: URL, isSending: Bool, progress: Double?, onCancel: (() -> Void)?) {
|
|
||||||
self.url = url
|
|
||||||
self.isSending = isSending
|
|
||||||
self.progress = progress
|
|
||||||
self.onCancel = onCancel
|
|
||||||
}
|
|
||||||
|
|
||||||
private var fileName: String {
|
|
||||||
url.lastPathComponent
|
|
||||||
}
|
|
||||||
|
|
||||||
private var normalizedProgress: Double? {
|
|
||||||
guard let progress = progress else { return nil }
|
|
||||||
return max(0, min(1, progress))
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(alignment: .center, spacing: 12) {
|
|
||||||
Image(systemName: "doc.fill")
|
|
||||||
.foregroundColor(Color.blue)
|
|
||||||
.font(.bitchatSystem(size: 24))
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(fileName)
|
|
||||||
.font(.bitchatSystem(size: 14, weight: .medium))
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
.lineLimit(2)
|
|
||||||
Text(url.lastPathComponent)
|
|
||||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
if let progress = normalizedProgress {
|
|
||||||
ProgressView(value: progress)
|
|
||||||
.progressViewStyle(.linear)
|
|
||||||
.tint(Color.blue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button(action: openFile) {
|
|
||||||
Text("open", comment: "Button to open attached file")
|
|
||||||
.font(.bitchatSystem(size: 13, weight: .semibold))
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.background(
|
|
||||||
Capsule().fill(Color.blue.opacity(0.15))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
|
||||||
if let onCancel = onCancel, isSending {
|
|
||||||
Button(action: onCancel) {
|
|
||||||
Image(systemName: "xmark")
|
|
||||||
.font(.bitchatSystem(size: 11, weight: .bold))
|
|
||||||
.frame(width: 26, height: 26)
|
|
||||||
.background(Circle().fill(Color.red.opacity(0.9)))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(12)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 14)
|
|
||||||
.fill(colorScheme == .dark ? Color.black.opacity(0.6) : Color.white)
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 14)
|
|
||||||
.stroke(Color.gray.opacity(0.2), lineWidth: 1)
|
|
||||||
)
|
|
||||||
#if os(iOS)
|
|
||||||
.sheet(isPresented: $showExporter) {
|
|
||||||
FileExportController(url: url)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private func openFile() {
|
|
||||||
#if os(iOS)
|
|
||||||
showExporter = true
|
|
||||||
#else
|
|
||||||
NSWorkspace.shared.open(url)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
import UniformTypeIdentifiers
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
private struct FileExportController: UIViewControllerRepresentable {
|
|
||||||
let url: URL
|
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
|
||||||
let controller = UIDocumentPickerViewController(forExporting: [url])
|
|
||||||
controller.shouldShowFileExtensions = true
|
|
||||||
return controller
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
import AppKit
|
|
||||||
#endif
|
|
||||||
@@ -91,61 +91,3 @@ struct LocationNotesManagerTests {
|
|||||||
case shouldNotDerive
|
case shouldNotDerive
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct LocationNotesCounterTests {
|
|
||||||
@Test func subscribeWithoutRelaysMarksUnavailable() {
|
|
||||||
var subscribeCalled = false
|
|
||||||
let deps = LocationNotesCounterDependencies(
|
|
||||||
relayLookup: { _, _ in [] },
|
|
||||||
subscribe: { _, _, _, _, _ in subscribeCalled = true },
|
|
||||||
unsubscribe: { _ in }
|
|
||||||
)
|
|
||||||
|
|
||||||
let counter = LocationNotesCounter(testDependencies: deps)
|
|
||||||
counter.subscribe(geohash: "u4pruydq")
|
|
||||||
|
|
||||||
#expect(!subscribeCalled)
|
|
||||||
#expect(!counter.relayAvailable)
|
|
||||||
#expect(counter.initialLoadComplete)
|
|
||||||
#expect(counter.count == 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func subscribeCountsUniqueNotes() {
|
|
||||||
var storedHandler: ((NostrEvent) -> Void)?
|
|
||||||
var storedEOSE: (() -> Void)?
|
|
||||||
let deps = LocationNotesCounterDependencies(
|
|
||||||
relayLookup: { _, _ in ["wss://relay.geo"] },
|
|
||||||
subscribe: { filter, id, relays, handler, eose in
|
|
||||||
#expect(relays == ["wss://relay.geo"])
|
|
||||||
#expect(filter.kinds == [1])
|
|
||||||
#expect(!id.isEmpty)
|
|
||||||
storedHandler = handler
|
|
||||||
storedEOSE = eose
|
|
||||||
},
|
|
||||||
unsubscribe: { _ in }
|
|
||||||
)
|
|
||||||
|
|
||||||
let counter = LocationNotesCounter(testDependencies: deps)
|
|
||||||
counter.subscribe(geohash: "u4pruydq")
|
|
||||||
|
|
||||||
var first = NostrEvent(
|
|
||||||
pubkey: "pub",
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .textNote,
|
|
||||||
tags: [["g", "u4pruydq"]],
|
|
||||||
content: "a"
|
|
||||||
)
|
|
||||||
first.id = "eventA"
|
|
||||||
storedHandler?(first)
|
|
||||||
|
|
||||||
let duplicate = first
|
|
||||||
storedHandler?(duplicate)
|
|
||||||
|
|
||||||
storedEOSE?()
|
|
||||||
|
|
||||||
#expect(counter.relayAvailable)
|
|
||||||
#expect(counter.count == 1)
|
|
||||||
#expect(counter.initialLoadComplete)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user