mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
Fix delivery confirmation for private messages
- Pass message ID from ChatViewModel to BluetoothMeshService - Preserve original message ID when creating ACK messages - Move delivery tracking to ChatViewModel for consistent IDs - Update project.yml to match current project structure
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 63;
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -28,6 +28,7 @@
|
||||
7DCA0DBCB8884E3B31C7BCE3 /* CompressionUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F149C43D1915831B60FE09 /* CompressionUtil.swift */; };
|
||||
7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */; };
|
||||
846E2B446E36639159704730 /* BloomFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB3A8FE16333ED12FCB8ACB /* BloomFilterTests.swift */; };
|
||||
8DCEEA289EF7C49E7CD38B08 /* DeliveryTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12B9C3EDF3BC73D3BC106DA4 /* DeliveryTracker.swift */; };
|
||||
8F0BFC2D2B2A5E7B70C3B485 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53D535D9CE0B875F47402290 /* BinaryProtocolTests.swift */; };
|
||||
8F737CE0435792CC2AD65FCB /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136696FC4436A02D98CE6A77 /* KeychainManager.swift */; };
|
||||
923027D6F2F417AFA2488127 /* BitchatProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */; };
|
||||
@@ -40,6 +41,7 @@
|
||||
C0A80BA73EC1A372B9338E3C /* BatteryOptimizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED176FF3B274E35C2D827894 /* BatteryOptimizer.swift */; };
|
||||
C91FDE97070433E6CFE95C55 /* BloomFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB3A8FE16333ED12FCB8ACB /* BloomFilterTests.swift */; };
|
||||
C99763A4761567F587D21688 /* MessageRetryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA4D7595A613F7ED3B386132 /* MessageRetryService.swift */; };
|
||||
CD0AE423F03AC52BAFC16834 /* DeliveryTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12B9C3EDF3BC73D3BC106DA4 /* DeliveryTracker.swift */; };
|
||||
CDAD6629EB69916B95C80DAF /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53D535D9CE0B875F47402290 /* BinaryProtocolTests.swift */; };
|
||||
CEAE115C9C3EB3C4ED82F128 /* MessageRetryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA4D7595A613F7ED3B386132 /* MessageRetryService.swift */; };
|
||||
D450CF41F207BDE1A1AAA56E /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */; };
|
||||
@@ -70,6 +72,7 @@
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
12B9C3EDF3BC73D3BC106DA4 /* DeliveryTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeliveryTracker.swift; sourceTree = "<group>"; };
|
||||
136696FC4436A02D98CE6A77 /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = "<group>"; };
|
||||
1EB3A8FE16333ED12FCB8ACB /* BloomFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloomFilterTests.swift; sourceTree = "<group>"; };
|
||||
229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatProtocol.swift; sourceTree = "<group>"; };
|
||||
@@ -81,9 +84,9 @@
|
||||
67A85BFDDE65B4CD8BDF6DDB /* MessageRetentionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRetentionService.swift; sourceTree = "<group>"; };
|
||||
6DC1563390A15C042D059CF9 /* EncryptionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionService.swift; sourceTree = "<group>"; };
|
||||
763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfoView.swift; sourceTree = "<group>"; };
|
||||
7EEBDA723E1CFD88758DA4AC /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
8DE9CDF66D4E52D268851048 /* MessagePaddingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagePaddingTests.swift; sourceTree = "<group>"; };
|
||||
997D512074C64904D75DDD40 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
8F3A7C058C2C8E1A06C8CF8B /* bitchat_macOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat_macOS.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
96D0D41CA19EE5A772AA8434 /* bitchat_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = bitchat_iOS.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
A08E03AA0C63E97C91749AEC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = "<group>"; };
|
||||
AA4D7595A613F7ED3B386132 /* MessageRetryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRetryService.swift; sourceTree = "<group>"; };
|
||||
@@ -144,8 +147,8 @@
|
||||
9F37F9F2C353B58AC809E93B /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
997D512074C64904D75DDD40 /* bitchat.app */,
|
||||
7EEBDA723E1CFD88758DA4AC /* bitchat.app */,
|
||||
96D0D41CA19EE5A772AA8434 /* bitchat_iOS.app */,
|
||||
8F3A7C058C2C8E1A06C8CF8B /* bitchat_macOS.app */,
|
||||
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */,
|
||||
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */,
|
||||
);
|
||||
@@ -187,6 +190,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D5C3D880FF8AE1673B20E1E3 /* BluetoothMeshService.swift */,
|
||||
12B9C3EDF3BC73D3BC106DA4 /* DeliveryTracker.swift */,
|
||||
6DC1563390A15C042D059CF9 /* EncryptionService.swift */,
|
||||
136696FC4436A02D98CE6A77 /* KeychainManager.swift */,
|
||||
67A85BFDDE65B4CD8BDF6DDB /* MessageRetentionService.swift */,
|
||||
@@ -214,7 +218,7 @@
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = bitchat_macOS;
|
||||
productReference = 7EEBDA723E1CFD88758DA4AC /* bitchat.app */;
|
||||
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat_macOS.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
47FF23248747DD7CB666CB91 /* bitchatTests_macOS */ = {
|
||||
@@ -268,7 +272,7 @@
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = bitchat_iOS;
|
||||
productReference = 997D512074C64904D75DDD40 /* bitchat.app */;
|
||||
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat_iOS.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
@@ -298,6 +302,7 @@
|
||||
);
|
||||
mainGroup = 18198ED912AAF495D8AF7763;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
preferredProjectObjectVersion = 54;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
@@ -342,6 +347,7 @@
|
||||
D450CF41F207BDE1A1AAA56E /* ChatViewModel.swift in Sources */,
|
||||
B0CA7796B2B2AC2B33F84548 /* CompressionUtil.swift in Sources */,
|
||||
92D34E7A07C990C8A815B0CE /* ContentView.swift in Sources */,
|
||||
8DCEEA289EF7C49E7CD38B08 /* DeliveryTracker.swift in Sources */,
|
||||
739429DFDE5C5829CF70DA7D /* EncryptionService.swift in Sources */,
|
||||
FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */,
|
||||
4E778E5A414571ACAC2A0F01 /* MessageRetentionService.swift in Sources */,
|
||||
@@ -364,6 +370,7 @@
|
||||
7576A357B278E5733E9D9F33 /* ChatViewModel.swift in Sources */,
|
||||
7DCA0DBCB8884E3B31C7BCE3 /* CompressionUtil.swift in Sources */,
|
||||
1D9674FA5F998503831DC281 /* ContentView.swift in Sources */,
|
||||
CD0AE423F03AC52BAFC16834 /* DeliveryTracker.swift in Sources */,
|
||||
DDA1DFAB1FF7AADE52DC0F53 /* EncryptionService.swift in Sources */,
|
||||
8F737CE0435792CC2AD65FCB /* KeychainManager.swift in Sources */,
|
||||
4274B6016F755946FBF2513E /* MessageRetentionService.swift in Sources */,
|
||||
@@ -425,6 +432,7 @@
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.tests;
|
||||
PRODUCT_NAME = bitchatTests;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
@@ -444,6 +452,7 @@
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.tests;
|
||||
PRODUCT_NAME = bitchatTests;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
@@ -464,6 +473,7 @@
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.tests;
|
||||
PRODUCT_NAME = bitchatTests;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bitchat.app/Contents/MacOS/bitchat";
|
||||
@@ -479,25 +489,19 @@
|
||||
CODE_SIGNING_REQUIRED = YES;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = L3N5LHJD5Y;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_FILE = bitchat/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
@@ -514,6 +518,7 @@
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.tests;
|
||||
PRODUCT_NAME = bitchatTests;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bitchat.app/Contents/MacOS/bitchat";
|
||||
@@ -529,25 +534,19 @@
|
||||
CODE_SIGNING_REQUIRED = YES;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = L3N5LHJD5Y;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_FILE = bitchat/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
@@ -560,9 +559,9 @@
|
||||
CODE_SIGNING_REQUIRED = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_FILE = bitchat/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
@@ -571,7 +570,6 @@
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = macosx;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
@@ -610,7 +608,7 @@
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
@@ -643,9 +641,9 @@
|
||||
CODE_SIGNING_REQUIRED = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_FILE = bitchat/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
@@ -654,7 +652,6 @@
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = macosx;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
@@ -693,7 +690,7 @@
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
|
||||
+2
-14
@@ -26,19 +26,7 @@
|
||||
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>bluetooth-central</string>
|
||||
<string>bluetooth-peripheral</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string>Black</string>
|
||||
</dict>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
<key>NSUserNotificationAlertStyle</key>
|
||||
<string>alert</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -74,6 +74,8 @@ enum MessageType: UInt8 {
|
||||
case fragmentEnd = 0x07
|
||||
case roomAnnounce = 0x08 // Announce password-protected room status
|
||||
case roomRetention = 0x09 // Announce room retention status
|
||||
case deliveryAck = 0x0A // Acknowledge message received
|
||||
case deliveryStatusRequest = 0x0B // Request delivery status update
|
||||
}
|
||||
|
||||
// Special recipient ID for broadcast messages
|
||||
@@ -127,6 +129,57 @@ struct BitchatPacket: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
// Delivery acknowledgment structure
|
||||
struct DeliveryAck: Codable {
|
||||
let originalMessageID: String
|
||||
let ackID: String
|
||||
let recipientID: String // Who received it
|
||||
let recipientNickname: String
|
||||
let timestamp: Date
|
||||
let hopCount: UInt8 // How many hops to reach recipient
|
||||
|
||||
init(originalMessageID: String, recipientID: String, recipientNickname: String, hopCount: UInt8) {
|
||||
self.originalMessageID = originalMessageID
|
||||
self.ackID = UUID().uuidString
|
||||
self.recipientID = recipientID
|
||||
self.recipientNickname = recipientNickname
|
||||
self.timestamp = Date()
|
||||
self.hopCount = hopCount
|
||||
}
|
||||
|
||||
func encode() -> Data? {
|
||||
try? JSONEncoder().encode(self)
|
||||
}
|
||||
|
||||
static func decode(from data: Data) -> DeliveryAck? {
|
||||
try? JSONDecoder().decode(DeliveryAck.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
// Delivery status for messages
|
||||
enum DeliveryStatus: Codable, Equatable {
|
||||
case sending
|
||||
case sent // Left our device
|
||||
case delivered(to: String, at: Date) // Confirmed by recipient
|
||||
case failed(reason: String)
|
||||
case partiallyDelivered(reached: Int, total: Int) // For rooms
|
||||
|
||||
var displayText: String {
|
||||
switch self {
|
||||
case .sending:
|
||||
return "Sending..."
|
||||
case .sent:
|
||||
return "Sent"
|
||||
case .delivered(let nickname, _):
|
||||
return "Delivered to \(nickname)"
|
||||
case .failed(let reason):
|
||||
return "Failed: \(reason)"
|
||||
case .partiallyDelivered(let reached, let total):
|
||||
return "Delivered to \(reached)/\(total)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct BitchatMessage: Codable, Equatable {
|
||||
let id: String
|
||||
let sender: String
|
||||
@@ -141,9 +194,10 @@ struct BitchatMessage: Codable, Equatable {
|
||||
let room: String? // Room hashtag (e.g., "#general")
|
||||
let encryptedContent: Data? // For password-protected rooms
|
||||
let isEncrypted: Bool // Flag to indicate if content is encrypted
|
||||
var deliveryStatus: DeliveryStatus? // Delivery tracking
|
||||
|
||||
init(sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, room: String? = nil, encryptedContent: Data? = nil, isEncrypted: Bool = false) {
|
||||
self.id = UUID().uuidString
|
||||
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, room: String? = nil, encryptedContent: Data? = nil, isEncrypted: Bool = false, deliveryStatus: DeliveryStatus? = nil) {
|
||||
self.id = id ?? UUID().uuidString
|
||||
self.sender = sender
|
||||
self.content = content
|
||||
self.timestamp = timestamp
|
||||
@@ -156,6 +210,7 @@ struct BitchatMessage: Codable, Equatable {
|
||||
self.room = room
|
||||
self.encryptedContent = encryptedContent
|
||||
self.isEncrypted = isEncrypted
|
||||
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +226,10 @@ protocol BitchatDelegate: AnyObject {
|
||||
|
||||
// Optional method to check if a fingerprint belongs to a favorite peer
|
||||
func isFavorite(fingerprint: String) -> Bool
|
||||
|
||||
// Delivery confirmation methods
|
||||
func didReceiveDeliveryAck(_ ack: DeliveryAck)
|
||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
|
||||
}
|
||||
|
||||
// Provide default implementation to make it effectively optional
|
||||
@@ -195,4 +254,12 @@ extension BitchatDelegate {
|
||||
// Default returns nil (unable to decrypt)
|
||||
return nil
|
||||
}
|
||||
|
||||
func didReceiveDeliveryAck(_ ack: DeliveryAck) {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {
|
||||
// Default empty implementation
|
||||
}
|
||||
}
|
||||
@@ -558,14 +558,18 @@ class BluetoothMeshService: NSObject {
|
||||
}
|
||||
|
||||
|
||||
func sendPrivateMessage(_ content: String, to recipientPeerID: String, recipientNickname: String) {
|
||||
func sendPrivateMessage(_ content: String, to recipientPeerID: String, recipientNickname: String, messageID: String? = nil) {
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
let nickname = self.delegate as? ChatViewModel
|
||||
let senderNick = nickname?.nickname ?? self.myPeerID
|
||||
|
||||
// Use provided message ID or generate a new one
|
||||
let msgID = messageID ?? UUID().uuidString
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: msgID,
|
||||
sender: senderNick,
|
||||
content: content,
|
||||
timestamp: Date(),
|
||||
@@ -634,6 +638,8 @@ class BluetoothMeshService: NSObject {
|
||||
self?.recentlySentMessages.remove(msgID)
|
||||
}
|
||||
|
||||
// Message tracking is now done in ChatViewModel to ensure consistent message IDs
|
||||
|
||||
// Add random delay for timing obfuscation
|
||||
let delay = self.randomDelay()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
@@ -666,6 +672,41 @@ class BluetoothMeshService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
func sendDeliveryAck(_ ack: DeliveryAck, to recipientID: String) {
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Encode the ACK
|
||||
guard let ackData = ack.encode() else {
|
||||
print("[DeliveryTracker] Failed to encode ACK")
|
||||
return
|
||||
}
|
||||
|
||||
// Encrypt ACK for the original sender
|
||||
let encryptedPayload: Data
|
||||
do {
|
||||
encryptedPayload = try self.encryptionService.encrypt(ackData, for: recipientID)
|
||||
} catch {
|
||||
print("[DeliveryTracker] Failed to encrypt ACK: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
// Create ACK packet with direct routing to original sender
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.deliveryAck.rawValue,
|
||||
senderID: Data(self.myPeerID.utf8),
|
||||
recipientID: Data(recipientID.utf8),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: encryptedPayload,
|
||||
signature: nil, // ACKs don't need signatures
|
||||
ttl: 3 // Limited TTL for ACKs
|
||||
)
|
||||
|
||||
// Send immediately without delay (ACKs should be fast)
|
||||
self.broadcastPacket(packet)
|
||||
}
|
||||
}
|
||||
|
||||
func announcePasswordProtectedRoom(_ room: String, isProtected: Bool = true, creatorID: String? = nil, keyCommitment: String? = nil) {
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
@@ -1371,6 +1412,7 @@ class BluetoothMeshService: NSObject {
|
||||
}
|
||||
|
||||
let messageWithPeerID = BitchatMessage(
|
||||
id: message.id, // Preserve the original message ID
|
||||
sender: message.sender,
|
||||
content: finalContent,
|
||||
timestamp: message.timestamp,
|
||||
@@ -1393,6 +1435,22 @@ class BluetoothMeshService: NSObject {
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.didReceiveMessage(messageWithPeerID)
|
||||
}
|
||||
|
||||
// Generate and send ACK for room messages if we're mentioned or it's a small room
|
||||
let viewModel = self.delegate as? ChatViewModel
|
||||
let myNickname = viewModel?.nickname ?? self.myPeerID
|
||||
if let _ = message.room,
|
||||
let mentions = message.mentions,
|
||||
(mentions.contains(myNickname) || self.activePeers.count < 10) {
|
||||
if let ack = DeliveryTracker.shared.generateAck(
|
||||
for: messageWithPeerID,
|
||||
myPeerID: self.myPeerID,
|
||||
myNickname: myNickname,
|
||||
hopCount: UInt8(self.maxTTL - packet.ttl)
|
||||
) {
|
||||
self.sendDeliveryAck(ack, to: senderID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Relay broadcast messages
|
||||
@@ -1476,6 +1534,7 @@ class BluetoothMeshService: NSObject {
|
||||
peerNicknamesLock.unlock()
|
||||
|
||||
let messageWithPeerID = BitchatMessage(
|
||||
id: message.id, // Preserve the original message ID
|
||||
sender: message.sender,
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
@@ -1496,6 +1555,18 @@ class BluetoothMeshService: NSObject {
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.didReceiveMessage(messageWithPeerID)
|
||||
}
|
||||
|
||||
// Generate and send ACK for private messages
|
||||
let viewModel = self.delegate as? ChatViewModel
|
||||
let myNickname = viewModel?.nickname ?? self.myPeerID
|
||||
if let ack = DeliveryTracker.shared.generateAck(
|
||||
for: messageWithPeerID,
|
||||
myPeerID: self.myPeerID,
|
||||
myNickname: myNickname,
|
||||
hopCount: UInt8(self.maxTTL - packet.ttl)
|
||||
) {
|
||||
self.sendDeliveryAck(ack, to: senderID)
|
||||
}
|
||||
}
|
||||
|
||||
} else if packet.ttl > 0 {
|
||||
@@ -1845,6 +1916,35 @@ class BluetoothMeshService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
case .deliveryAck:
|
||||
// Handle delivery acknowledgment
|
||||
if let recipientIDData = packet.recipientID,
|
||||
let recipientID = String(data: recipientIDData.trimmingNullBytes(), encoding: .utf8),
|
||||
recipientID == myPeerID {
|
||||
// This ACK is for us - decrypt it
|
||||
if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) {
|
||||
do {
|
||||
let decryptedData = try encryptionService.decrypt(packet.payload, from: senderID)
|
||||
if let ack = DeliveryAck.decode(from: decryptedData) {
|
||||
// Process the ACK
|
||||
DeliveryTracker.shared.processDeliveryAck(ack)
|
||||
|
||||
// Notify delegate
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.didReceiveDeliveryAck(ack)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Failed to decrypt ACK - might be from unknown sender
|
||||
}
|
||||
}
|
||||
} else if packet.ttl > 0 {
|
||||
// Relay the ACK if not for us
|
||||
var relayPacket = packet
|
||||
relayPacket.ttl -= 1
|
||||
self.broadcastPacket(relayPacket)
|
||||
}
|
||||
|
||||
case .roomRetention:
|
||||
if let payloadStr = String(data: packet.payload, encoding: .utf8) {
|
||||
// Parse payload: room|enabled|creatorID
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
//
|
||||
// DeliveryTracker.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
class DeliveryTracker {
|
||||
static let shared = DeliveryTracker()
|
||||
|
||||
// Track pending deliveries
|
||||
private var pendingDeliveries: [String: PendingDelivery] = [:]
|
||||
private let pendingLock = NSLock()
|
||||
|
||||
// Track received ACKs to prevent duplicates
|
||||
private var receivedAckIDs = Set<String>()
|
||||
private var sentAckIDs = Set<String>()
|
||||
|
||||
// Timeout configuration
|
||||
private let privateMessageTimeout: TimeInterval = 30 // 30 seconds
|
||||
private let roomMessageTimeout: TimeInterval = 60 // 1 minute
|
||||
private let favoriteTimeout: TimeInterval = 300 // 5 minutes for favorites
|
||||
|
||||
// Retry configuration
|
||||
private let maxRetries = 3
|
||||
private let retryDelay: TimeInterval = 5 // Base retry delay
|
||||
|
||||
// Publishers for UI updates
|
||||
let deliveryStatusUpdated = PassthroughSubject<(messageID: String, status: DeliveryStatus), Never>()
|
||||
|
||||
// Cleanup timer
|
||||
private var cleanupTimer: Timer?
|
||||
|
||||
struct PendingDelivery {
|
||||
let messageID: String
|
||||
let sentAt: Date
|
||||
let recipientID: String
|
||||
let recipientNickname: String
|
||||
let retryCount: Int
|
||||
let isRoomMessage: Bool
|
||||
let isFavorite: Bool
|
||||
var ackedBy: Set<String> = [] // For tracking partial room delivery
|
||||
let expectedRecipients: Int // For room messages
|
||||
var timeoutTimer: Timer?
|
||||
|
||||
var isTimedOut: Bool {
|
||||
let timeout: TimeInterval = isFavorite ? 300 : (isRoomMessage ? 60 : 30)
|
||||
return Date().timeIntervalSince(sentAt) > timeout
|
||||
}
|
||||
|
||||
var shouldRetry: Bool {
|
||||
return retryCount < 3 && isFavorite && !isRoomMessage
|
||||
}
|
||||
}
|
||||
|
||||
private init() {
|
||||
startCleanupTimer()
|
||||
}
|
||||
|
||||
deinit {
|
||||
cleanupTimer?.invalidate()
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
func trackMessage(_ message: BitchatMessage, recipientID: String, recipientNickname: String, isFavorite: Bool = false, expectedRecipients: Int = 1) {
|
||||
pendingLock.lock()
|
||||
defer { pendingLock.unlock() }
|
||||
|
||||
// Don't track broadcasts or certain message types
|
||||
guard message.isPrivate || message.room != nil else { return }
|
||||
|
||||
let delivery = PendingDelivery(
|
||||
messageID: message.id,
|
||||
sentAt: Date(),
|
||||
recipientID: recipientID,
|
||||
recipientNickname: recipientNickname,
|
||||
retryCount: 0,
|
||||
isRoomMessage: message.room != nil,
|
||||
isFavorite: isFavorite,
|
||||
expectedRecipients: expectedRecipients,
|
||||
timeoutTimer: nil
|
||||
)
|
||||
|
||||
pendingDeliveries[message.id] = delivery
|
||||
|
||||
// Update status to sent
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||
self?.updateDeliveryStatus(message.id, status: .sent)
|
||||
}
|
||||
|
||||
// Schedule timeout
|
||||
scheduleTimeout(for: message.id)
|
||||
}
|
||||
|
||||
func processDeliveryAck(_ ack: DeliveryAck) {
|
||||
pendingLock.lock()
|
||||
defer { pendingLock.unlock() }
|
||||
|
||||
// Prevent duplicate ACK processing
|
||||
guard !receivedAckIDs.contains(ack.ackID) else { return }
|
||||
receivedAckIDs.insert(ack.ackID)
|
||||
|
||||
// Find the pending delivery
|
||||
guard var delivery = pendingDeliveries[ack.originalMessageID] else {
|
||||
// Message might have already been delivered or timed out
|
||||
return
|
||||
}
|
||||
|
||||
// Cancel timeout timer
|
||||
delivery.timeoutTimer?.invalidate()
|
||||
|
||||
if delivery.isRoomMessage {
|
||||
// Track partial delivery for room messages
|
||||
delivery.ackedBy.insert(ack.recipientID)
|
||||
pendingDeliveries[ack.originalMessageID] = delivery
|
||||
|
||||
let deliveredCount = delivery.ackedBy.count
|
||||
let totalExpected = delivery.expectedRecipients
|
||||
|
||||
if deliveredCount >= totalExpected || deliveredCount >= max(1, totalExpected / 2) {
|
||||
// Consider delivered if we got ACKs from at least half the expected recipients
|
||||
updateDeliveryStatus(ack.originalMessageID, status: .delivered(to: "\(deliveredCount) members", at: Date()))
|
||||
pendingDeliveries.removeValue(forKey: ack.originalMessageID)
|
||||
} else {
|
||||
// Update partial delivery status
|
||||
updateDeliveryStatus(ack.originalMessageID, status: .partiallyDelivered(reached: deliveredCount, total: totalExpected))
|
||||
}
|
||||
} else {
|
||||
// Direct message - mark as delivered
|
||||
updateDeliveryStatus(ack.originalMessageID, status: .delivered(to: ack.recipientNickname, at: Date()))
|
||||
pendingDeliveries.removeValue(forKey: ack.originalMessageID)
|
||||
}
|
||||
}
|
||||
|
||||
func generateAck(for message: BitchatMessage, myPeerID: String, myNickname: String, hopCount: UInt8) -> DeliveryAck? {
|
||||
// Don't ACK our own messages
|
||||
guard message.senderPeerID != myPeerID else { return nil }
|
||||
|
||||
// Don't ACK broadcasts or system messages
|
||||
guard message.isPrivate || message.room != nil else { return nil }
|
||||
|
||||
// Don't ACK if we've already sent an ACK for this message
|
||||
guard !sentAckIDs.contains(message.id) else { return nil }
|
||||
sentAckIDs.insert(message.id)
|
||||
|
||||
return DeliveryAck(
|
||||
originalMessageID: message.id,
|
||||
recipientID: myPeerID,
|
||||
recipientNickname: myNickname,
|
||||
hopCount: hopCount
|
||||
)
|
||||
}
|
||||
|
||||
func clearDeliveryStatus(for messageID: String) {
|
||||
pendingLock.lock()
|
||||
defer { pendingLock.unlock() }
|
||||
|
||||
if let delivery = pendingDeliveries[messageID] {
|
||||
delivery.timeoutTimer?.invalidate()
|
||||
}
|
||||
pendingDeliveries.removeValue(forKey: messageID)
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func updateDeliveryStatus(_ messageID: String, status: DeliveryStatus) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.deliveryStatusUpdated.send((messageID: messageID, status: status))
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleTimeout(for messageID: String) {
|
||||
guard let delivery = pendingDeliveries[messageID] else { return }
|
||||
|
||||
let timeout = delivery.isFavorite ? favoriteTimeout :
|
||||
(delivery.isRoomMessage ? roomMessageTimeout : privateMessageTimeout)
|
||||
|
||||
let timer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { [weak self] _ in
|
||||
self?.handleTimeout(messageID: messageID)
|
||||
}
|
||||
|
||||
pendingLock.lock()
|
||||
if var updatedDelivery = pendingDeliveries[messageID] {
|
||||
updatedDelivery.timeoutTimer = timer
|
||||
pendingDeliveries[messageID] = updatedDelivery
|
||||
}
|
||||
pendingLock.unlock()
|
||||
}
|
||||
|
||||
private func handleTimeout(messageID: String) {
|
||||
pendingLock.lock()
|
||||
defer { pendingLock.unlock() }
|
||||
|
||||
guard let delivery = pendingDeliveries[messageID] else { return }
|
||||
|
||||
if delivery.shouldRetry {
|
||||
// Retry for favorites
|
||||
retryDelivery(messageID: messageID)
|
||||
} else {
|
||||
// Mark as failed
|
||||
let reason = delivery.isRoomMessage ? "No response from room members" : "Message not delivered"
|
||||
updateDeliveryStatus(messageID, status: .failed(reason: reason))
|
||||
pendingDeliveries.removeValue(forKey: messageID)
|
||||
}
|
||||
}
|
||||
|
||||
private func retryDelivery(messageID: String) {
|
||||
guard let delivery = pendingDeliveries[messageID] else { return }
|
||||
|
||||
// Increment retry count
|
||||
let newDelivery = PendingDelivery(
|
||||
messageID: delivery.messageID,
|
||||
sentAt: delivery.sentAt,
|
||||
recipientID: delivery.recipientID,
|
||||
recipientNickname: delivery.recipientNickname,
|
||||
retryCount: delivery.retryCount + 1,
|
||||
isRoomMessage: delivery.isRoomMessage,
|
||||
isFavorite: delivery.isFavorite,
|
||||
ackedBy: delivery.ackedBy,
|
||||
expectedRecipients: delivery.expectedRecipients,
|
||||
timeoutTimer: nil
|
||||
)
|
||||
|
||||
pendingDeliveries[messageID] = newDelivery
|
||||
|
||||
// Exponential backoff for retry
|
||||
let delay = retryDelay * pow(2, Double(delivery.retryCount))
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
// Trigger resend through delegate or notification
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.Name("bitchat.retryMessage"),
|
||||
object: nil,
|
||||
userInfo: ["messageID": messageID]
|
||||
)
|
||||
|
||||
// Schedule new timeout
|
||||
self?.scheduleTimeout(for: messageID)
|
||||
}
|
||||
}
|
||||
|
||||
private func startCleanupTimer() {
|
||||
cleanupTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in
|
||||
self?.cleanupOldDeliveries()
|
||||
}
|
||||
}
|
||||
|
||||
private func cleanupOldDeliveries() {
|
||||
pendingLock.lock()
|
||||
defer { pendingLock.unlock() }
|
||||
|
||||
let now = Date()
|
||||
let maxAge: TimeInterval = 3600 // 1 hour
|
||||
|
||||
// Clean up old pending deliveries
|
||||
pendingDeliveries = pendingDeliveries.filter { (_, delivery) in
|
||||
now.timeIntervalSince(delivery.sentAt) < maxAge
|
||||
}
|
||||
|
||||
// Clean up old ACK IDs (keep last 1000)
|
||||
if receivedAckIDs.count > 1000 {
|
||||
receivedAckIDs.removeAll()
|
||||
}
|
||||
if sentAckIDs.count > 1000 {
|
||||
sentAckIDs.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,9 @@ class ChatViewModel: ObservableObject {
|
||||
|
||||
// Messages are naturally ephemeral - no persistent storage
|
||||
|
||||
// Delivery tracking
|
||||
private var deliveryTrackerCancellable: AnyCancellable?
|
||||
|
||||
init() {
|
||||
loadNickname()
|
||||
loadFavorites()
|
||||
@@ -87,6 +90,13 @@ class ChatViewModel: ObservableObject {
|
||||
|
||||
// Request notification permission
|
||||
NotificationService.shared.requestAuthorization()
|
||||
|
||||
// Subscribe to delivery status updates
|
||||
deliveryTrackerCancellable = DeliveryTracker.shared.deliveryStatusUpdated
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] (messageID, status) in
|
||||
self?.updateMessageDeliveryStatus(messageID, status: status)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadNickname() {
|
||||
@@ -847,7 +857,8 @@ class ChatViewModel: ObservableObject {
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: recipientNickname,
|
||||
senderPeerID: meshService.myPeerID
|
||||
senderPeerID: meshService.myPeerID,
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
|
||||
// Add to our private chat history
|
||||
@@ -856,11 +867,15 @@ class ChatViewModel: ObservableObject {
|
||||
}
|
||||
privateChats[peerID]?.append(message)
|
||||
|
||||
// Track the message for delivery confirmation
|
||||
let isFavorite = isFavorite(peerID: peerID)
|
||||
DeliveryTracker.shared.trackMessage(message, recipientID: peerID, recipientNickname: recipientNickname, isFavorite: isFavorite)
|
||||
|
||||
// Trigger UI update
|
||||
objectWillChange.send()
|
||||
|
||||
// Send via mesh
|
||||
meshService.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname)
|
||||
// Send via mesh with the same message ID
|
||||
meshService.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: message.id)
|
||||
}
|
||||
|
||||
func startPrivateChat(with peerID: String) {
|
||||
@@ -2030,4 +2045,42 @@ extension ChatViewModel: BitchatDelegate {
|
||||
return favoritePeers.contains(fingerprint)
|
||||
}
|
||||
|
||||
func didReceiveDeliveryAck(_ ack: DeliveryAck) {
|
||||
// Find the message and update its delivery status
|
||||
updateMessageDeliveryStatus(ack.originalMessageID, status: .delivered(to: ack.recipientNickname, at: ack.timestamp))
|
||||
}
|
||||
|
||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {
|
||||
updateMessageDeliveryStatus(messageID, status: status)
|
||||
}
|
||||
|
||||
private func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {
|
||||
// Update in main messages
|
||||
if let index = messages.firstIndex(where: { $0.id == messageID }) {
|
||||
var updatedMessage = messages[index]
|
||||
updatedMessage.deliveryStatus = status
|
||||
messages[index] = updatedMessage
|
||||
}
|
||||
|
||||
// Update in private chats
|
||||
for (peerID, var chatMessages) in privateChats {
|
||||
if let index = chatMessages.firstIndex(where: { $0.id == messageID }) {
|
||||
var updatedMessage = chatMessages[index]
|
||||
updatedMessage.deliveryStatus = status
|
||||
chatMessages[index] = updatedMessage
|
||||
privateChats[peerID] = chatMessages
|
||||
}
|
||||
}
|
||||
|
||||
// Update in room messages
|
||||
for (room, var roomMsgs) in roomMessages {
|
||||
if let index = roomMsgs.firstIndex(where: { $0.id == messageID }) {
|
||||
var updatedMessage = roomMsgs[index]
|
||||
updatedMessage.deliveryStatus = status
|
||||
roomMsgs[index] = updatedMessage
|
||||
roomMessages[room] = roomMsgs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -453,7 +453,7 @@ struct ContentView: View {
|
||||
.textSelection(.enabled)
|
||||
} else {
|
||||
// Regular messages with tappable sender name
|
||||
HStack(alignment: .firstTextBaseline, spacing: 0) {
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
// Timestamp
|
||||
Text("[\(viewModel.formatTimestamp(message.timestamp))] ")
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
@@ -491,6 +491,14 @@ struct ContentView: View {
|
||||
isMentioned: isMentioned
|
||||
)
|
||||
|
||||
// Delivery status indicator for private messages
|
||||
if message.isPrivate && message.sender == viewModel.nickname,
|
||||
let status = message.deliveryStatus {
|
||||
DeliveryStatusView(status: status, colorScheme: colorScheme)
|
||||
.padding(.leading, 4)
|
||||
.alignmentGuide(.firstTextBaseline) { _ in 12 }
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
@@ -1061,3 +1069,57 @@ struct MessageContentView: View {
|
||||
return segments
|
||||
}
|
||||
}
|
||||
|
||||
// Delivery status indicator view
|
||||
struct DeliveryStatusView: View {
|
||||
let status: DeliveryStatus
|
||||
let colorScheme: ColorScheme
|
||||
|
||||
private var textColor: Color {
|
||||
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||
}
|
||||
|
||||
private var secondaryTextColor: Color {
|
||||
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
switch status {
|
||||
case .sending:
|
||||
Image(systemName: "circle")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
|
||||
case .sent:
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
|
||||
case .delivered(let nickname, _):
|
||||
HStack(spacing: -2) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 10))
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 10))
|
||||
}
|
||||
.foregroundColor(textColor.opacity(0.8))
|
||||
.help("Delivered to \(nickname)")
|
||||
|
||||
case .failed(let reason):
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(Color.red.opacity(0.8))
|
||||
.help("Failed: \(reason)")
|
||||
|
||||
case .partiallyDelivered(let reached, let total):
|
||||
HStack(spacing: 1) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 10))
|
||||
Text("\(reached)/\(total)")
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
}
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
.help("Delivered to \(reached) of \(total) members")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+63
-16
@@ -5,17 +5,62 @@ options:
|
||||
iOS: 16.0
|
||||
macOS: 13.0
|
||||
createIntermediateGroups: true
|
||||
groupSortPosition: bottom
|
||||
|
||||
settings:
|
||||
MARKETING_VERSION: 1.0.0
|
||||
CURRENT_PROJECT_VERSION: 1
|
||||
CURRENT_PROJECT_VERSION: 5
|
||||
|
||||
targets:
|
||||
bitchat:
|
||||
bitchat_iOS:
|
||||
type: application
|
||||
platform: [iOS, macOS]
|
||||
platform: iOS
|
||||
sources:
|
||||
- bitchat
|
||||
- path: bitchat
|
||||
excludes:
|
||||
- "**/.DS_Store"
|
||||
createIntermediateGroups: true
|
||||
resources:
|
||||
- bitchat/Assets.xcassets
|
||||
info:
|
||||
path: bitchat/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: bitchat
|
||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||
NSBluetoothAlwaysUsageDescription: bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.
|
||||
NSBluetoothPeripheralUsageDescription: bitchat uses Bluetooth to discover and connect with other bitchat users nearby.
|
||||
NSUserNotificationAlertStyle: alert
|
||||
UIBackgroundModes:
|
||||
- bluetooth-central
|
||||
- bluetooth-peripheral
|
||||
- remote-notification
|
||||
UILaunchScreen:
|
||||
UIColorName: Black
|
||||
UISupportedInterfaceOrientations:
|
||||
- UIInterfaceOrientationPortrait
|
||||
settings:
|
||||
PRODUCT_NAME: bitchat
|
||||
PRODUCT_BUNDLE_IDENTIFIER: chat.bitchat
|
||||
INFOPLIST_FILE: bitchat/Info.plist
|
||||
ENABLE_PREVIEWS: YES
|
||||
SWIFT_VERSION: 5.0
|
||||
IPHONEOS_DEPLOYMENT_TARGET: 16.0
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
CODE_SIGNING_REQUIRED: YES
|
||||
CODE_SIGNING_ALLOWED: YES
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: YES
|
||||
|
||||
bitchat_macOS:
|
||||
type: application
|
||||
platform: macOS
|
||||
sources:
|
||||
- path: bitchat
|
||||
excludes:
|
||||
- "**/.DS_Store"
|
||||
createIntermediateGroups: true
|
||||
resources:
|
||||
- bitchat/Assets.xcassets
|
||||
info:
|
||||
@@ -27,35 +72,33 @@ targets:
|
||||
LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
|
||||
NSBluetoothAlwaysUsageDescription: bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.
|
||||
NSBluetoothPeripheralUsageDescription: bitchat uses Bluetooth to discover and connect with other bitchat users nearby.
|
||||
UIBackgroundModes:
|
||||
- bluetooth-central
|
||||
- bluetooth-peripheral
|
||||
UILaunchScreen:
|
||||
UIColorName: Black
|
||||
UISupportedInterfaceOrientations:
|
||||
- UIInterfaceOrientationPortrait
|
||||
NSUserNotificationAlertStyle: alert
|
||||
settings:
|
||||
PRODUCT_NAME: bitchat
|
||||
PRODUCT_BUNDLE_IDENTIFIER: chat.bitchat
|
||||
INFOPLIST_FILE: bitchat/Info.plist
|
||||
ENABLE_PREVIEWS: YES
|
||||
SWIFT_VERSION: 5.0
|
||||
IPHONEOS_DEPLOYMENT_TARGET: 16.0
|
||||
MACOSX_DEPLOYMENT_TARGET: 13.0
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: YES
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
CODE_SIGNING_REQUIRED: YES
|
||||
CODE_SIGNING_ALLOWED: YES
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: YES
|
||||
ENABLE_HARDENED_RUNTIME: YES
|
||||
|
||||
bitchatTests_iOS:
|
||||
type: bundle.unit-test
|
||||
platform: iOS
|
||||
sources:
|
||||
- bitchatTests
|
||||
- path: bitchatTests
|
||||
excludes:
|
||||
- "**/.DS_Store"
|
||||
createIntermediateGroups: true
|
||||
dependencies:
|
||||
- target: bitchat_iOS
|
||||
settings:
|
||||
PRODUCT_NAME: bitchatTests
|
||||
PRODUCT_BUNDLE_IDENTIFIER: chat.bitchat.tests
|
||||
INFOPLIST_FILE: bitchatTests/Info.plist
|
||||
SWIFT_VERSION: 5.0
|
||||
@@ -67,13 +110,17 @@ targets:
|
||||
type: bundle.unit-test
|
||||
platform: macOS
|
||||
sources:
|
||||
- bitchatTests
|
||||
- path: bitchatTests
|
||||
excludes:
|
||||
- "**/.DS_Store"
|
||||
createIntermediateGroups: true
|
||||
dependencies:
|
||||
- target: bitchat_macOS
|
||||
settings:
|
||||
PRODUCT_NAME: bitchatTests
|
||||
PRODUCT_BUNDLE_IDENTIFIER: chat.bitchat.tests
|
||||
INFOPLIST_FILE: bitchatTests/Info.plist
|
||||
SWIFT_VERSION: 5.0
|
||||
MACOSX_DEPLOYMENT_TARGET: 13.0
|
||||
TEST_HOST: $(BUILT_PRODUCTS_DIR)/bitchat.app/Contents/MacOS/bitchat
|
||||
BUNDLE_LOADER: $(TEST_HOST)
|
||||
BUNDLE_LOADER: $(TEST_HOST)
|
||||
Reference in New Issue
Block a user