mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 05:05:19 +00:00
Cut views over to ConversationStore; delete legacy store, bridge, resolver
Feature models observe per-conversation objects directly: PublicChatModel forwards the active Conversation's objectWillChange, PrivateInboxModel republishes only for the selected peer's conversation - background appends no longer invalidate foreground views. LegacyConversationStore, the coalescing bridge, and IdentityResolver are deleted (resolver canonicalization proved display-invisible: nothing enumerates direct conversations, lookups are by exact peer ID, and raw keying is strictly more robust - documented as a design deviation). Selection state moves into the store. ChatViewModel.messages/privateChats survive as derived read views for coordinators that genuinely need them; hot paths use a new store-direct privateMessages(for:) witness. Final numbers vs pre-migration baselines: pipeline.privateIngest 9.7k -> 24.0k msg/s (2.5x) pipeline.publicIngest 6.8k -> 13.7k msg/s (2.0x) delivery updates 38k -> 117-133k/s Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
@@ -45,6 +46,27 @@ private func makeArchitectureSnapshot(
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeArchitectureMessage(
|
||||
id: String,
|
||||
timestamp: TimeInterval = 0,
|
||||
content: String? = nil,
|
||||
isPrivate: Bool = false,
|
||||
senderPeerID: PeerID = PeerID(str: "peer-a")
|
||||
) -> BitchatMessage {
|
||||
BitchatMessage(
|
||||
id: id,
|
||||
sender: "alice",
|
||||
content: content ?? "message \(id)",
|
||||
timestamp: Date(timeIntervalSince1970: timestamp),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: isPrivate,
|
||||
recipientNickname: isPrivate ? "builder" : nil,
|
||||
senderPeerID: senderPeerID
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func waitUntil(
|
||||
timeoutNanoseconds: UInt64 = 3_000_000_000,
|
||||
@@ -127,251 +149,119 @@ struct AppArchitectureTests {
|
||||
|
||||
@Test("PeerHandle equality and hashing use the canonical identity only")
|
||||
func peerHandleEqualityUsesCanonicalIdentity() {
|
||||
let first = PeerHandle(
|
||||
id: "noise:abc123",
|
||||
routingPeerID: PeerID(str: "peer-a"),
|
||||
displayName: "alice",
|
||||
noisePublicKeyHex: "abc123",
|
||||
nostrPublicKey: nil
|
||||
)
|
||||
let second = PeerHandle(
|
||||
id: "noise:abc123",
|
||||
routingPeerID: PeerID(str: "peer-b"),
|
||||
displayName: "alice-renamed",
|
||||
noisePublicKeyHex: nil,
|
||||
nostrPublicKey: "npub123"
|
||||
)
|
||||
let first = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-a"))
|
||||
let second = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-b"))
|
||||
|
||||
#expect(first == second)
|
||||
#expect(Set([first, second]).count == 1)
|
||||
}
|
||||
|
||||
@Test("LegacyConversationStore normalizes timeline ordering and duplicates")
|
||||
@Test("ConversationStore orders timelines and replaces duplicates by message ID")
|
||||
@MainActor
|
||||
func conversationStoreNormalizesMessages() {
|
||||
let store = LegacyConversationStore()
|
||||
let older = BitchatMessage(
|
||||
id: "m1",
|
||||
sender: "alice",
|
||||
content: "first",
|
||||
timestamp: Date(timeIntervalSince1970: 1),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: PeerID(str: "peer-a")
|
||||
)
|
||||
let newer = BitchatMessage(
|
||||
id: "m2",
|
||||
sender: "alice",
|
||||
content: "second",
|
||||
timestamp: Date(timeIntervalSince1970: 2),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: PeerID(str: "peer-a")
|
||||
)
|
||||
let replacement = BitchatMessage(
|
||||
id: "m2",
|
||||
sender: "alice",
|
||||
content: "second-updated",
|
||||
timestamp: Date(timeIntervalSince1970: 2),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: PeerID(str: "peer-a")
|
||||
)
|
||||
func conversationStoreOrdersAndDedupsMessages() {
|
||||
let store = ConversationStore()
|
||||
let older = makeArchitectureMessage(id: "m1", timestamp: 1, content: "first")
|
||||
let newer = makeArchitectureMessage(id: "m2", timestamp: 2, content: "second")
|
||||
let replacement = makeArchitectureMessage(id: "m2", timestamp: 2, content: "second-updated")
|
||||
|
||||
store.replaceMessages([newer, older, replacement], for: ConversationID.mesh)
|
||||
store.append(newer, to: .mesh)
|
||||
store.append(older, to: .mesh)
|
||||
store.upsertByID(replacement, in: .mesh)
|
||||
|
||||
let messages = store.messages(for: ConversationID.mesh)
|
||||
let messages = store.conversation(for: .mesh).messages
|
||||
#expect(messages.map(\.id) == ["m1", "m2"])
|
||||
#expect(messages.last?.content == "second-updated")
|
||||
}
|
||||
|
||||
@Test("LegacyConversationStore tracks unread direct conversations with canonical IDs")
|
||||
@Test("ConversationStore tracks unread direct conversations by routing peer ID")
|
||||
@MainActor
|
||||
func conversationStoreTracksUnreadDirectConversations() {
|
||||
let store = LegacyConversationStore()
|
||||
let resolver = IdentityResolver()
|
||||
let store = ConversationStore()
|
||||
let peerID = PeerID(str: "peer-1")
|
||||
let message = BitchatMessage(
|
||||
id: "dm-1",
|
||||
sender: "alice",
|
||||
content: "hello",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: "bob",
|
||||
senderPeerID: peerID
|
||||
)
|
||||
let message = makeArchitectureMessage(id: "dm-1", isPrivate: true, senderPeerID: peerID)
|
||||
|
||||
store.synchronizePrivateChats(
|
||||
[peerID: [message]],
|
||||
unreadPeerIDs: Set([peerID]),
|
||||
identityResolver: resolver
|
||||
)
|
||||
store.append(message, to: .directPeer(peerID))
|
||||
store.markUnread(.directPeer(peerID))
|
||||
|
||||
let conversationID = ConversationID.direct(
|
||||
resolver.canonicalHandle(for: peerID, displayName: "alice")
|
||||
)
|
||||
#expect(store.conversation(for: .directPeer(peerID)).messages.map(\.id) == ["dm-1"])
|
||||
#expect(store.unreadDirectRoutingPeerIDs() == Set([peerID]))
|
||||
#expect(store.conversation(for: .directPeer(peerID)).isUnread)
|
||||
|
||||
#expect(store.messages(for: conversationID).map(\.id) == ["dm-1"])
|
||||
#expect(store.unreadConversations.contains(conversationID))
|
||||
|
||||
store.markRead(conversationID)
|
||||
#expect(!store.unreadConversations.contains(conversationID))
|
||||
store.markRead(.directPeer(peerID))
|
||||
#expect(store.unreadDirectRoutingPeerIDs().isEmpty)
|
||||
#expect(!store.conversation(for: .directPeer(peerID)).isUnread)
|
||||
}
|
||||
|
||||
@Test("LegacyConversationStore tracks the selected app conversation context")
|
||||
@Test("ConversationStore derives the selected conversation from channel and private peer")
|
||||
@MainActor
|
||||
func conversationStoreTracksSelectedConversationContext() {
|
||||
let store = LegacyConversationStore()
|
||||
let resolver = IdentityResolver()
|
||||
let noiseKey = Data((0..<32).map(UInt8.init))
|
||||
let shortPeerID = PeerID(str: "0011223344556677")
|
||||
let store = ConversationStore()
|
||||
let peerID = PeerID(str: "0011223344556677")
|
||||
let geohashChannel = ChannelID.location(GeohashChannel(level: .city, geohash: "9q8yy"))
|
||||
let peer = BitchatPeer(
|
||||
peerID: shortPeerID,
|
||||
noisePublicKey: noiseKey,
|
||||
nickname: "alice",
|
||||
isConnected: true,
|
||||
isReachable: true
|
||||
)
|
||||
|
||||
resolver.register(peers: [peer])
|
||||
store.synchronizeSelection(
|
||||
activeChannel: geohashChannel,
|
||||
selectedPeerID: shortPeerID,
|
||||
identityResolver: resolver
|
||||
)
|
||||
|
||||
let expectedConversationID = ConversationID.direct(
|
||||
resolver.canonicalHandle(for: shortPeerID, displayName: "alice")
|
||||
)
|
||||
store.setActiveChannel(geohashChannel)
|
||||
store.setSelectedPrivatePeer(peerID)
|
||||
|
||||
#expect(store.activeChannel == geohashChannel)
|
||||
#expect(store.selectedPrivatePeerID == shortPeerID)
|
||||
#expect(store.selectedConversationID == expectedConversationID)
|
||||
#expect(store.selectedPrivatePeerID == peerID)
|
||||
// The open private chat wins the derived selection.
|
||||
#expect(store.selectedConversationID == ConversationID.directPeer(peerID))
|
||||
|
||||
store.synchronizeSelection(
|
||||
activeChannel: ChannelID.mesh,
|
||||
selectedPeerID: nil,
|
||||
identityResolver: resolver
|
||||
)
|
||||
store.setSelectedPrivatePeer(nil)
|
||||
// Selection falls back to the active public channel.
|
||||
#expect(store.selectedConversationID == ConversationID(channelID: geohashChannel))
|
||||
|
||||
store.setActiveChannel(.mesh)
|
||||
#expect(store.activeChannel == ChannelID.mesh)
|
||||
#expect(store.selectedPrivatePeerID == nil)
|
||||
#expect(store.selectedConversationID == ConversationID.mesh)
|
||||
}
|
||||
|
||||
@Test("LegacyConversationStore exposes direct conversations by the latest routing peer ID")
|
||||
@Test("ConversationStore re-keys a direct conversation via the migrate intent")
|
||||
@MainActor
|
||||
func conversationStoreExposesDirectConversationsByLatestRoutingPeerID() {
|
||||
let store = LegacyConversationStore()
|
||||
let resolver = IdentityResolver()
|
||||
func conversationStoreMigratesDirectConversationsBetweenPeerIDs() {
|
||||
let store = ConversationStore()
|
||||
let noiseKey = Data((0..<32).map(UInt8.init))
|
||||
let shortPeerID = PeerID(str: "0011223344556677")
|
||||
let fullPeerID = PeerID(hexData: noiseKey)
|
||||
let firstMessage = BitchatMessage(
|
||||
id: "dm-1",
|
||||
sender: "alice",
|
||||
content: "short id",
|
||||
timestamp: Date(timeIntervalSince1970: 1),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: "builder",
|
||||
senderPeerID: shortPeerID
|
||||
)
|
||||
let secondMessage = BitchatMessage(
|
||||
id: "dm-2",
|
||||
sender: "alice",
|
||||
content: "full id",
|
||||
timestamp: Date(timeIntervalSince1970: 2),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: "builder",
|
||||
senderPeerID: fullPeerID
|
||||
)
|
||||
|
||||
resolver.register(
|
||||
peer: BitchatPeer(
|
||||
peerID: shortPeerID,
|
||||
noisePublicKey: noiseKey,
|
||||
nickname: "alice",
|
||||
isConnected: true,
|
||||
isReachable: true
|
||||
)
|
||||
)
|
||||
store.synchronizePrivateChats(
|
||||
[shortPeerID: [firstMessage]],
|
||||
unreadPeerIDs: Set([shortPeerID]),
|
||||
identityResolver: resolver
|
||||
store.append(
|
||||
makeArchitectureMessage(id: "dm-1", timestamp: 1, isPrivate: true, senderPeerID: shortPeerID),
|
||||
to: .directPeer(shortPeerID)
|
||||
)
|
||||
store.markUnread(.directPeer(shortPeerID))
|
||||
store.setSelectedPrivatePeer(shortPeerID)
|
||||
|
||||
resolver.register(
|
||||
peer: BitchatPeer(
|
||||
peerID: fullPeerID,
|
||||
noisePublicKey: noiseKey,
|
||||
nickname: "alice",
|
||||
isConnected: true,
|
||||
isReachable: true
|
||||
)
|
||||
)
|
||||
store.synchronizePrivateChats(
|
||||
[fullPeerID: [secondMessage]],
|
||||
unreadPeerIDs: Set([fullPeerID]),
|
||||
identityResolver: resolver
|
||||
)
|
||||
store.migrateConversation(from: .directPeer(shortPeerID), to: .directPeer(fullPeerID))
|
||||
|
||||
#expect(Set(store.directMessagesByPeerID().keys) == Set([fullPeerID]))
|
||||
#expect(store.directMessagesByPeerID()[fullPeerID]?.map(\.id) == ["dm-2"])
|
||||
#expect(store.unreadDirectPeerIDs() == Set([fullPeerID]))
|
||||
// Raw keying: the old peer's conversation is gone, the new peer's
|
||||
// conversation holds the timeline, unread and selection carried over.
|
||||
#expect(store.conversationsByID[.directPeer(shortPeerID)] == nil)
|
||||
#expect(Set(store.directMessagesByRoutingPeerID().keys) == Set([fullPeerID]))
|
||||
#expect(store.directMessagesByRoutingPeerID()[fullPeerID]?.map(\.id) == ["dm-1"])
|
||||
#expect(store.unreadDirectRoutingPeerIDs() == Set([fullPeerID]))
|
||||
#expect(store.selectedPrivatePeerID == fullPeerID)
|
||||
#expect(store.selectedConversationID == ConversationID.directPeer(fullPeerID))
|
||||
}
|
||||
|
||||
@Test("PrivateInboxModel mirrors direct message state from LegacyConversationStore")
|
||||
@Test("PrivateInboxModel reads direct message state from the ConversationStore")
|
||||
@MainActor
|
||||
func privateInboxModelMirrorsDirectMessageStateFromConversationStore() async {
|
||||
let store = LegacyConversationStore()
|
||||
let resolver = IdentityResolver()
|
||||
let inboxModel = PrivateInboxModel(conversationStore: store)
|
||||
func privateInboxModelReadsDirectMessageStateFromConversationStore() {
|
||||
let store = ConversationStore()
|
||||
let inboxModel = PrivateInboxModel(conversations: store)
|
||||
let messagePeerID = PeerID(str: "peer-1")
|
||||
let unreadOnlyPeerID = PeerID(str: "peer-2")
|
||||
let selectedOnlyPeerID = PeerID(str: "peer-3")
|
||||
let message = BitchatMessage(
|
||||
id: "dm-1",
|
||||
sender: "alice",
|
||||
content: "hello",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: "builder",
|
||||
senderPeerID: messagePeerID
|
||||
)
|
||||
|
||||
store.synchronizePrivateChats(
|
||||
[messagePeerID: [message]],
|
||||
unreadPeerIDs: Set([messagePeerID, unreadOnlyPeerID]),
|
||||
identityResolver: resolver
|
||||
)
|
||||
store.synchronizeSelection(
|
||||
activeChannel: ChannelID.mesh,
|
||||
selectedPeerID: selectedOnlyPeerID,
|
||||
identityResolver: resolver
|
||||
store.append(
|
||||
makeArchitectureMessage(id: "dm-1", isPrivate: true, senderPeerID: messagePeerID),
|
||||
to: .directPeer(messagePeerID)
|
||||
)
|
||||
store.markUnread(.directPeer(messagePeerID))
|
||||
store.markUnread(.directPeer(unreadOnlyPeerID))
|
||||
store.setSelectedPrivatePeer(selectedOnlyPeerID)
|
||||
|
||||
await waitUntil {
|
||||
inboxModel.selectedPeerID == selectedOnlyPeerID &&
|
||||
inboxModel.unreadPeerIDs == Set([messagePeerID, unreadOnlyPeerID]) &&
|
||||
Set(inboxModel.messagesByPeerID.keys) == Set([messagePeerID, unreadOnlyPeerID, selectedOnlyPeerID])
|
||||
}
|
||||
|
||||
// Reads are synchronous against the single-writer store.
|
||||
#expect(inboxModel.selectedPeerID == selectedOnlyPeerID)
|
||||
#expect(inboxModel.unreadPeerIDs == Set([messagePeerID, unreadOnlyPeerID]))
|
||||
#expect(inboxModel.messages(for: messagePeerID).map(\.id) == ["dm-1"])
|
||||
@@ -379,13 +269,74 @@ struct AppArchitectureTests {
|
||||
#expect(inboxModel.messages(for: selectedOnlyPeerID).isEmpty)
|
||||
}
|
||||
|
||||
@Test("PrivateInboxModel republishes only for the selected conversation")
|
||||
@MainActor
|
||||
func privateInboxModelIsolatesBackgroundConversations() {
|
||||
let store = ConversationStore()
|
||||
let inboxModel = PrivateInboxModel(conversations: store)
|
||||
let selectedPeerID = PeerID(str: "peer-selected")
|
||||
let backgroundPeerID = PeerID(str: "peer-background")
|
||||
store.setSelectedPrivatePeer(selectedPeerID)
|
||||
|
||||
var emissions = 0
|
||||
let cancellable = inboxModel.objectWillChange.sink { _ in emissions += 1 }
|
||||
defer { cancellable.cancel() }
|
||||
|
||||
let baseline = emissions
|
||||
store.append(
|
||||
makeArchitectureMessage(id: "dm-bg-1", isPrivate: true, senderPeerID: backgroundPeerID),
|
||||
to: .directPeer(backgroundPeerID)
|
||||
)
|
||||
// An append to a background chat does not republish the model.
|
||||
#expect(emissions == baseline)
|
||||
|
||||
store.append(
|
||||
makeArchitectureMessage(id: "dm-sel-1", isPrivate: true, senderPeerID: selectedPeerID),
|
||||
to: .directPeer(selectedPeerID)
|
||||
)
|
||||
#expect(emissions == baseline + 1)
|
||||
#expect(inboxModel.messages(for: selectedPeerID).map(\.id) == ["dm-sel-1"])
|
||||
}
|
||||
|
||||
@Test("PublicChatModel ignores appends to background conversations")
|
||||
@MainActor
|
||||
func publicChatModelIsolatesBackgroundConversations() {
|
||||
let store = ConversationStore()
|
||||
store.setActiveChannel(.mesh)
|
||||
let model = PublicChatModel(conversations: store)
|
||||
|
||||
var emissions = 0
|
||||
let cancellable = model.objectWillChange.sink { _ in emissions += 1 }
|
||||
defer { cancellable.cancel() }
|
||||
|
||||
store.append(makeArchitectureMessage(id: "mesh-1"), to: .mesh)
|
||||
let afterActiveAppend = emissions
|
||||
#expect(afterActiveAppend >= 1)
|
||||
#expect(model.messages.map(\.id) == ["mesh-1"])
|
||||
|
||||
// Appends to a background geohash channel and to a private chat do
|
||||
// not invalidate the observer of the active conversation.
|
||||
store.append(makeArchitectureMessage(id: "geo-1"), to: .geohash("u4pruyd"))
|
||||
store.append(
|
||||
makeArchitectureMessage(id: "dm-1", isPrivate: true),
|
||||
to: .directPeer(PeerID(str: "peer-1"))
|
||||
)
|
||||
#expect(emissions == afterActiveAppend)
|
||||
#expect(model.messages.map(\.id) == ["mesh-1"])
|
||||
|
||||
// Switching the channel retargets the observation.
|
||||
store.setActiveChannel(.location(GeohashChannel(level: .neighborhood, geohash: "u4pruyd")))
|
||||
#expect(model.messages.map(\.id) == ["geo-1"])
|
||||
store.append(makeArchitectureMessage(id: "geo-2", timestamp: 1), to: .geohash("u4pruyd"))
|
||||
#expect(model.messages.map(\.id) == ["geo-1", "geo-2"])
|
||||
}
|
||||
|
||||
@Test("AppChromeModel mirrors nickname and unread state through focused models")
|
||||
@MainActor
|
||||
func appChromeModelMirrorsNicknameAndUnreadState() async {
|
||||
let viewModel = makeArchitectureViewModel()
|
||||
let conversationStore = LegacyConversationStore()
|
||||
let resolver = IdentityResolver()
|
||||
let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
|
||||
let conversations = ConversationStore()
|
||||
let privateInboxModel = PrivateInboxModel(conversations: conversations)
|
||||
let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel)
|
||||
|
||||
chromeModel.setNickname("builder")
|
||||
@@ -398,11 +349,7 @@ struct AppArchitectureTests {
|
||||
#expect(!chromeModel.hasUnreadPrivateMessages)
|
||||
|
||||
let peerID = PeerID(str: "peer-1")
|
||||
conversationStore.synchronizePrivateChats(
|
||||
[:],
|
||||
unreadPeerIDs: Set([peerID]),
|
||||
identityResolver: resolver
|
||||
)
|
||||
conversations.markUnread(.directPeer(peerID))
|
||||
await waitUntil {
|
||||
chromeModel.hasUnreadPrivateMessages
|
||||
}
|
||||
@@ -414,8 +361,7 @@ struct AppArchitectureTests {
|
||||
@MainActor
|
||||
func appChromeModelOwnsPresentationState() {
|
||||
let viewModel = makeArchitectureViewModel()
|
||||
let conversationStore = LegacyConversationStore()
|
||||
let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
|
||||
let privateInboxModel = PrivateInboxModel(conversations: ConversationStore())
|
||||
let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel)
|
||||
let peerID = PeerID(str: "peer-2")
|
||||
|
||||
@@ -441,11 +387,10 @@ struct AppArchitectureTests {
|
||||
Issue.record("Expected ChatViewModel meshService to be a MockTransport in architecture tests")
|
||||
return
|
||||
}
|
||||
let conversationStore = viewModel.conversationStore
|
||||
let locationChannelsModel = LocationChannelsModel(manager: makeArchitectureLocationManager())
|
||||
let conversationModel = PrivateConversationModel(
|
||||
chatViewModel: viewModel,
|
||||
conversationStore: conversationStore,
|
||||
conversations: viewModel.conversations,
|
||||
locationChannelsModel: locationChannelsModel
|
||||
)
|
||||
|
||||
@@ -493,18 +438,17 @@ struct AppArchitectureTests {
|
||||
return
|
||||
}
|
||||
|
||||
let conversationStore = viewModel.conversationStore
|
||||
locationManager.select(.mesh)
|
||||
let locationChannelsModel = LocationChannelsModel(manager: locationManager)
|
||||
let privateConversationModel = PrivateConversationModel(
|
||||
chatViewModel: viewModel,
|
||||
conversationStore: conversationStore,
|
||||
conversations: viewModel.conversations,
|
||||
locationChannelsModel: locationChannelsModel
|
||||
)
|
||||
let uiModel = ConversationUIModel(
|
||||
chatViewModel: viewModel,
|
||||
privateConversationModel: privateConversationModel,
|
||||
conversationStore: conversationStore
|
||||
conversations: viewModel.conversations
|
||||
)
|
||||
let geohashChannel = ChannelID.location(GeohashChannel(level: .city, geohash: "9q8yy"))
|
||||
defer {
|
||||
@@ -558,11 +502,10 @@ struct AppArchitectureTests {
|
||||
|
||||
let peerID = PeerID(str: "0011223344556677")
|
||||
let fingerprint = "verified-fingerprint"
|
||||
let conversationStore = viewModel.conversationStore
|
||||
let locationChannelsModel = LocationChannelsModel(manager: makeArchitectureLocationManager())
|
||||
let privateConversationModel = PrivateConversationModel(
|
||||
chatViewModel: viewModel,
|
||||
conversationStore: conversationStore,
|
||||
conversations: viewModel.conversations,
|
||||
locationChannelsModel: locationChannelsModel
|
||||
)
|
||||
let verificationModel = VerificationModel(
|
||||
@@ -664,7 +607,7 @@ struct AppArchitectureTests {
|
||||
|
||||
let peerListModel = PeerListModel(
|
||||
chatViewModel: viewModel,
|
||||
conversationStore: viewModel.conversationStore,
|
||||
conversations: viewModel.conversations,
|
||||
locationChannelsModel: locationChannelsModel
|
||||
)
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ private final class MockChatLifecycleContext: ChatLifecycleContext {
|
||||
// Chat & receipt state
|
||||
var messages: [BitchatMessage] = []
|
||||
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
||||
|
||||
func privateMessages(for peerID: PeerID) -> [BitchatMessage] {
|
||||
privateChats[peerID] ?? []
|
||||
}
|
||||
var unreadPrivateMessages: Set<PeerID> = []
|
||||
var selectedPrivateChatPeer: PeerID?
|
||||
var sentReadReceipts: Set<String> = []
|
||||
|
||||
@@ -27,6 +27,10 @@ private final class MockChatPeerListContext: ChatPeerListContext {
|
||||
// Connection & chat state
|
||||
var isConnected = false
|
||||
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
||||
|
||||
func privateMessages(for peerID: PeerID) -> [BitchatMessage] {
|
||||
privateChats[peerID] ?? []
|
||||
}
|
||||
var unreadPrivateMessages: Set<PeerID> = []
|
||||
var hasTrackedPrivateChatSelection = false
|
||||
private(set) var updatePrivateChatPeerIfNeededCount = 0
|
||||
|
||||
@@ -28,6 +28,10 @@ private final class MockChatTransportEventContext: ChatTransportEventContext {
|
||||
var nickname = "me"
|
||||
var myPeerID = PeerID(str: "0011223344556677")
|
||||
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
||||
|
||||
func privateMessages(for peerID: PeerID) -> [BitchatMessage] {
|
||||
privateChats[peerID] ?? []
|
||||
}
|
||||
var unreadPrivateMessages: Set<PeerID> = []
|
||||
var selectedPrivateChatPeer: PeerID?
|
||||
private(set) var unmarkedReadReceiptBatches: [[String]] = []
|
||||
|
||||
@@ -553,10 +553,7 @@ struct ChatViewModelNoisePayloadTests {
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
|
||||
let conversationStoreUpdated = await TestHelpers.waitUntil({
|
||||
let messages = viewModel.conversationStore.directMessages(
|
||||
for: peerID,
|
||||
identityResolver: viewModel.identityResolver
|
||||
)
|
||||
let messages = viewModel.conversations.conversationsByID[.directPeer(peerID)]?.messages ?? []
|
||||
guard let status = messages.first?.deliveryStatus else { return false }
|
||||
if case .read = status {
|
||||
return true
|
||||
|
||||
@@ -41,10 +41,7 @@ private func makeMessage(
|
||||
private func makeDirectConversationID(_ suffix: String) -> ConversationID {
|
||||
.direct(PeerHandle(
|
||||
id: "noise:\(suffix)",
|
||||
routingPeerID: PeerID(str: "peer-\(suffix)"),
|
||||
displayName: "peer \(suffix)",
|
||||
noisePublicKeyHex: suffix,
|
||||
nostrPublicKey: nil
|
||||
routingPeerID: PeerID(str: "peer-\(suffix)")
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
//
|
||||
// LegacyConversationStoreBridgeTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// Focused tests for migration step 2 (docs/CONVERSATION-STORE-DESIGN.md §4):
|
||||
// the private write path goes through the single-writer `ConversationStore`
|
||||
// intents, and `LegacyConversationStoreBridge` keeps the replace-based
|
||||
// `LegacyConversationStore` consistent — per-message changes via a
|
||||
// `Task.yield`-coalesced per-conversation mirror, unread flips and
|
||||
// structural changes (migration/removal) synchronously — until the feature
|
||||
// models cut over in step 5.
|
||||
//
|
||||
// DELETE IN STEP 5 together with the bridge.
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
@MainActor
|
||||
private func makeBridgedFixture() -> (
|
||||
viewModel: ChatViewModel,
|
||||
store: ConversationStore,
|
||||
legacy: LegacyConversationStore,
|
||||
transport: MockTransport
|
||||
) {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
let identityManager = MockIdentityManager(keychain)
|
||||
let transport = MockTransport()
|
||||
let legacy = LegacyConversationStore()
|
||||
let store = ConversationStore()
|
||||
let viewModel = ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
transport: transport,
|
||||
conversationStore: legacy,
|
||||
conversations: store
|
||||
)
|
||||
return (viewModel, store, legacy, transport)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeInboundPrivateMessage(
|
||||
id: String,
|
||||
from peerID: PeerID,
|
||||
sender: String = "alice",
|
||||
content: String = "hello",
|
||||
timestamp: Date = Date()
|
||||
) -> BitchatMessage {
|
||||
BitchatMessage(
|
||||
id: id,
|
||||
sender: sender,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "me",
|
||||
senderPeerID: peerID,
|
||||
deliveryStatus: .delivered(to: "me", at: timestamp)
|
||||
)
|
||||
}
|
||||
|
||||
@Suite("LegacyConversationStoreBridge")
|
||||
struct LegacyConversationStoreBridgeTests {
|
||||
|
||||
@Test("inbound private message lands in the store and is mirrored into Legacy")
|
||||
@MainActor
|
||||
func inboundPrivateMessageLandsInStoreAndLegacy() async {
|
||||
let (viewModel, store, legacy, _) = makeBridgedFixture()
|
||||
// Stable Noise-key peer ID, like the perf pipeline fixture.
|
||||
let peerID = PeerID(str: String(repeating: "ab", count: 32))
|
||||
let message = makeInboundPrivateMessage(id: "bridge-pm-1", from: peerID)
|
||||
|
||||
viewModel.handlePrivateMessage(message)
|
||||
|
||||
// New store is the synchronous source of truth.
|
||||
#expect(store.conversation(for: .directPeer(peerID)).messages.map(\.id) == ["bridge-pm-1"])
|
||||
#expect(viewModel.privateChats[peerID]?.map(\.id) == ["bridge-pm-1"])
|
||||
// Unread flips mirror into Legacy synchronously.
|
||||
#expect(viewModel.unreadPrivateMessages.contains(peerID))
|
||||
#expect(legacy.unreadDirectPeerIDs().contains(peerID))
|
||||
|
||||
// Message arrays mirror via the bridge's coalesced flush
|
||||
// (one Legacy replace per burst, on the next main-actor turn).
|
||||
let mirrored = await TestHelpers.waitUntil(
|
||||
{ legacy.directMessagesByPeerID()[peerID]?.map(\.id) == ["bridge-pm-1"] },
|
||||
timeout: 1.0
|
||||
)
|
||||
#expect(mirrored)
|
||||
}
|
||||
|
||||
@Test("store dedups a private message delivered twice")
|
||||
@MainActor
|
||||
func storeDedupsDuplicateInboundMessage() async {
|
||||
let (viewModel, store, legacy, _) = makeBridgedFixture()
|
||||
let peerID = PeerID(str: String(repeating: "cd", count: 32))
|
||||
let message = makeInboundPrivateMessage(id: "bridge-dup-1", from: peerID)
|
||||
|
||||
viewModel.handlePrivateMessage(message)
|
||||
viewModel.handlePrivateMessage(message)
|
||||
|
||||
#expect(store.conversation(for: .directPeer(peerID)).messages.count == 1)
|
||||
#expect(viewModel.privateChats[peerID]?.count == 1)
|
||||
|
||||
// The intent itself reports the duplicate.
|
||||
#expect(!viewModel.appendPrivateMessage(message, to: peerID))
|
||||
|
||||
let mirrored = await TestHelpers.waitUntil(
|
||||
{ legacy.directMessagesByPeerID()[peerID]?.count == 1 },
|
||||
timeout: 1.0
|
||||
)
|
||||
#expect(mirrored)
|
||||
}
|
||||
|
||||
@Test("peer-ID migration through store intents keeps Legacy consistent")
|
||||
@MainActor
|
||||
func migrationThroughStoreIntentsKeepsLegacyConsistent() {
|
||||
let (viewModel, store, legacy, _) = makeBridgedFixture()
|
||||
let oldPeerID = PeerID(str: "aaaaaaaaaaaaaaaa")
|
||||
let newPeerID = PeerID(str: "bbbbbbbbbbbbbbbb")
|
||||
|
||||
viewModel.seedPrivateChat(
|
||||
[
|
||||
makeInboundPrivateMessage(id: "mig-1", from: oldPeerID, timestamp: Date().addingTimeInterval(-60)),
|
||||
makeInboundPrivateMessage(id: "mig-2", from: oldPeerID, timestamp: Date().addingTimeInterval(-30)),
|
||||
],
|
||||
for: oldPeerID
|
||||
)
|
||||
viewModel.markPrivateChatUnread(oldPeerID)
|
||||
|
||||
viewModel.migratePrivateChat(from: oldPeerID, to: newPeerID)
|
||||
|
||||
// Store: messages moved in order, old chat gone, unread carried.
|
||||
#expect(store.conversationsByID[.directPeer(oldPeerID)] == nil)
|
||||
#expect(store.conversation(for: .directPeer(newPeerID)).messages.map(\.id) == ["mig-1", "mig-2"])
|
||||
#expect(viewModel.unreadPrivateMessages == [newPeerID])
|
||||
|
||||
// Legacy: structural changes resynchronize immediately (stale
|
||||
// messages removed, destination present, unread re-keyed). The old
|
||||
// peer may linger as a registered-but-empty handle — Legacy has
|
||||
// always kept handle registrations from markRead/selection around.
|
||||
let legacyChats = legacy.directMessagesByPeerID()
|
||||
#expect(legacyChats[oldPeerID] ?? [] == [])
|
||||
#expect(legacyChats[newPeerID]?.map(\.id) == ["mig-1", "mig-2"])
|
||||
#expect(legacy.unreadDirectPeerIDs() == [newPeerID])
|
||||
}
|
||||
|
||||
@Test("coordinator chat migration uses the store migrate intent end to end")
|
||||
@MainActor
|
||||
func coordinatorMigrationFlowsThroughStore() {
|
||||
let (viewModel, store, legacy, _) = makeBridgedFixture()
|
||||
let oldPeerID = PeerID(str: "1111111111111111")
|
||||
let newPeerID = PeerID(str: "2222222222222222")
|
||||
|
||||
// Recent messages from "alice" under the old peer ID; no fingerprints
|
||||
// on either side → nickname-match migration path.
|
||||
viewModel.seedPrivateChat(
|
||||
[makeInboundPrivateMessage(id: "coord-mig-1", from: oldPeerID, timestamp: Date().addingTimeInterval(-5))],
|
||||
for: oldPeerID
|
||||
)
|
||||
|
||||
viewModel.migratePrivateChatsIfNeeded(for: newPeerID, senderNickname: "alice")
|
||||
|
||||
#expect(store.conversationsByID[.directPeer(oldPeerID)] == nil)
|
||||
#expect(store.conversation(for: .directPeer(newPeerID)).messages.map(\.id) == ["coord-mig-1"])
|
||||
// Migration resynchronizes Legacy immediately (the old peer may
|
||||
// linger as a registered-but-empty handle, see above).
|
||||
#expect(legacy.directMessagesByPeerID()[oldPeerID] ?? [] == [])
|
||||
#expect(legacy.directMessagesByPeerID()[newPeerID]?.map(\.id) == ["coord-mig-1"])
|
||||
}
|
||||
|
||||
// MARK: - Public mirroring (migration step 3)
|
||||
|
||||
@Test("public messages mirror into Legacy via the coalesced flush")
|
||||
@MainActor
|
||||
func publicMessagesMirrorIntoLegacy() async {
|
||||
let (viewModel, store, legacy, _) = makeBridgedFixture()
|
||||
|
||||
viewModel.appendPublicMessage(
|
||||
BitchatMessage(
|
||||
id: "bridge-pub-1",
|
||||
sender: "alice",
|
||||
content: "hello mesh",
|
||||
timestamp: Date(),
|
||||
isRelay: false
|
||||
),
|
||||
to: .mesh
|
||||
)
|
||||
viewModel.appendGeohashMessageIfAbsent(
|
||||
BitchatMessage(
|
||||
id: "bridge-geo-1",
|
||||
sender: "bob#abcd",
|
||||
content: "hello geohash",
|
||||
timestamp: Date(),
|
||||
isRelay: false
|
||||
),
|
||||
toGeohash: "U4PRUYD"
|
||||
)
|
||||
|
||||
// The new store is synchronously authoritative (geohash keys are
|
||||
// normalized to lowercase).
|
||||
#expect(store.conversation(for: .mesh).messages.map(\.id) == ["bridge-pub-1"])
|
||||
#expect(store.conversation(for: .geohash("u4pruyd")).messages.map(\.id) == ["bridge-geo-1"])
|
||||
|
||||
// Legacy catches up within one coalesced flush.
|
||||
let mirrored = await TestHelpers.waitUntil(
|
||||
{
|
||||
legacy.messages(for: .mesh).map(\.id) == ["bridge-pub-1"]
|
||||
&& legacy.messages(for: .geohash("u4pruyd")).map(\.id) == ["bridge-geo-1"]
|
||||
},
|
||||
timeout: 1.0
|
||||
)
|
||||
#expect(mirrored)
|
||||
}
|
||||
|
||||
@Test("removing a public conversation empties its Legacy mirror immediately")
|
||||
@MainActor
|
||||
func publicConversationRemovalClearsLegacy() async {
|
||||
let (viewModel, store, legacy, _) = makeBridgedFixture()
|
||||
viewModel.appendPublicMessage(
|
||||
BitchatMessage(
|
||||
id: "bridge-pub-2",
|
||||
sender: "alice",
|
||||
content: "soon gone",
|
||||
timestamp: Date(),
|
||||
isRelay: false
|
||||
),
|
||||
to: .mesh
|
||||
)
|
||||
let mirrored = await TestHelpers.waitUntil(
|
||||
{ legacy.messages(for: .mesh).map(\.id) == ["bridge-pub-2"] },
|
||||
timeout: 1.0
|
||||
)
|
||||
#expect(mirrored)
|
||||
|
||||
// Panic-style removal: Legacy must never show stale public messages.
|
||||
store.removeConversation(.mesh)
|
||||
#expect(legacy.messages(for: .mesh).isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -315,14 +315,13 @@ final class PerformanceBaselineTests: XCTestCase {
|
||||
// MARK: - 6a. End-to-end private ingest pipeline (current architecture)
|
||||
|
||||
/// Baseline for the full private-message ingest cycle through a real
|
||||
/// `ChatViewModel`: `handlePrivateMessage` → `privateChats` passthrough →
|
||||
/// `PrivateChatManager`'s `@Published` dict → bootstrapper Combine sink →
|
||||
/// `Task.yield`-debounced `LegacyConversationStore.synchronizePrivateChats`
|
||||
/// (full-dict replace) → `PrivateInboxModel.refreshMessages` (full
|
||||
/// rebuild). Measures wall time from first ingest until the store AND the
|
||||
/// feature model both reflect every message. The peer is not mesh-active
|
||||
/// and no chat is selected, so notification/read-receipt side paths stay
|
||||
/// cold (notifications are no-ops under test anyway).
|
||||
/// `ChatViewModel`: `handlePrivateMessage` → `ConversationStore.append`
|
||||
/// intent (ordered insert + ID-index dedup) → per-conversation publish +
|
||||
/// `changes` emission → `PrivateInboxModel` (direct store reads).
|
||||
/// Measures wall time from first ingest until the store AND the feature
|
||||
/// model both reflect every message. The peer is not mesh-active and no
|
||||
/// chat is selected, so notification/read-receipt side paths stay cold
|
||||
/// (notifications are no-ops under test anyway).
|
||||
func testPipelinePrivateIngest() {
|
||||
let messageCount = 200
|
||||
// 64-hex (stable Noise key) peer ID: skips the short-ID consolidation
|
||||
@@ -354,14 +353,14 @@ final class PerformanceBaselineTests: XCTestCase {
|
||||
for message in messages {
|
||||
fixture.viewModel.handlePrivateMessage(message)
|
||||
}
|
||||
// Drain the main queue until the debounced LegacyConversationStore sync
|
||||
// ran and the PrivateInboxModel mirror caught up.
|
||||
// Reads are synchronous against the single-writer store; the
|
||||
// spin covers any coordinator main-actor hops.
|
||||
let consistent = spinMainRunLoop(timeout: 10) {
|
||||
fixture.conversationStore.directMessagesByPeerID()[peerID]?.count == messageCount
|
||||
&& fixture.privateInbox.messagesByPeerID[peerID]?.count == messageCount
|
||||
fixture.conversations.conversationsByID[.directPeer(peerID)]?.messages.count == messageCount
|
||||
&& fixture.privateInbox.messages(for: peerID).count == messageCount
|
||||
}
|
||||
samples.append(Date().timeIntervalSince(start))
|
||||
XCTAssertTrue(consistent, "LegacyConversationStore/PrivateInboxModel never converged")
|
||||
XCTAssertTrue(consistent, "ConversationStore/PrivateInboxModel never converged")
|
||||
XCTAssertEqual(fixture.viewModel.privateChats[peerID]?.count, messageCount)
|
||||
}
|
||||
|
||||
@@ -374,8 +373,8 @@ final class PerformanceBaselineTests: XCTestCase {
|
||||
/// `ChatViewModel`: `didReceivePublicMessage` (transport delegate entry,
|
||||
/// main-actor Task hop per message) → `handlePublicMessage` (rate limit,
|
||||
/// pipeline enqueue) → `PublicMessagePipeline` timer-batched flush into
|
||||
/// the `ConversationStore` (derived `ChatViewModel.messages` view) →
|
||||
/// coalesced `LegacyConversationStoreBridge` mirror → `PublicChatModel`.
|
||||
/// the `ConversationStore` (derived `ChatViewModel.messages` view;
|
||||
/// `PublicChatModel` observes the active `Conversation` directly).
|
||||
/// Measures until `messages` and the feature model reflect every message,
|
||||
/// so the pipeline's flush latency is part of the cycle. Senders are
|
||||
/// spread 4-per-peer to stay under the 5-token sender rate bucket.
|
||||
@@ -419,8 +418,8 @@ final class PerformanceBaselineTests: XCTestCase {
|
||||
}
|
||||
// Drain the per-message main-actor hops and whichever surfacing
|
||||
// path wins (the pipeline's batched timer flush or the startup
|
||||
// channel apply's `refreshVisibleMessages`, which pulls the whole
|
||||
// timeline into `messages`), plus the PublicChatModel mirror.
|
||||
// channel apply's `refreshVisibleMessages`); `PublicChatModel`
|
||||
// reads the same conversation synchronously.
|
||||
let consistent = spinMainRunLoop(timeout: 10) {
|
||||
fixture.viewModel.messages.count >= messageCount
|
||||
&& fixture.publicChat.messages.count >= messageCount
|
||||
@@ -720,14 +719,14 @@ private final class PerfDeliveryContext: ChatDeliveryContext {
|
||||
|
||||
/// A real `ChatViewModel` over `MockTransport` plus the AppRuntime-style
|
||||
/// feature models (`PrivateInboxModel` / `PublicChatModel`) bound to the same
|
||||
/// `LegacyConversationStore`, so end-to-end ingest benchmarks cover the full
|
||||
/// current store-synchronization chain. Mirrors the construction used by
|
||||
/// single-writer `ConversationStore`, so end-to-end ingest benchmarks cover
|
||||
/// the full ingest-to-feature-model chain. Mirrors the construction used by
|
||||
/// `ChatViewModelExtensionsTests`.
|
||||
@MainActor
|
||||
private final class PerfPipelineFixture {
|
||||
let viewModel: ChatViewModel
|
||||
let transport: MockTransport
|
||||
let conversationStore: LegacyConversationStore
|
||||
let conversations: ConversationStore
|
||||
let privateInbox: PrivateInboxModel
|
||||
let publicChat: PublicChatModel
|
||||
|
||||
@@ -736,19 +735,19 @@ private final class PerfPipelineFixture {
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
let identityManager = MockIdentityManager(keychain)
|
||||
let transport = MockTransport()
|
||||
let conversationStore = LegacyConversationStore()
|
||||
let conversations = ConversationStore()
|
||||
|
||||
self.transport = transport
|
||||
self.conversationStore = conversationStore
|
||||
self.conversations = conversations
|
||||
self.viewModel = ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
transport: transport,
|
||||
conversationStore: conversationStore
|
||||
conversations: conversations
|
||||
)
|
||||
self.privateInbox = PrivateInboxModel(conversationStore: conversationStore)
|
||||
self.publicChat = PublicChatModel(conversationStore: conversationStore)
|
||||
self.privateInbox = PrivateInboxModel(conversations: conversations)
|
||||
self.publicChat = PublicChatModel(conversations: conversations)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,17 +52,17 @@ private func makeSmokeLocationManager() -> LocationChannelManager {
|
||||
@MainActor
|
||||
private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatureModels {
|
||||
let locationManager = makeSmokeLocationManager()
|
||||
let conversationStore = viewModel.conversationStore
|
||||
let publicChatModel = PublicChatModel(conversationStore: conversationStore)
|
||||
let conversations = viewModel.conversations
|
||||
let publicChatModel = PublicChatModel(conversations: conversations)
|
||||
let locationChannelsModel = LocationChannelsModel(manager: locationManager)
|
||||
let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
|
||||
let privateInboxModel = PrivateInboxModel(conversations: conversations)
|
||||
let appChromeModel = AppChromeModel(
|
||||
chatViewModel: viewModel,
|
||||
privateInboxModel: privateInboxModel
|
||||
)
|
||||
let privateConversationModel = PrivateConversationModel(
|
||||
chatViewModel: viewModel,
|
||||
conversationStore: conversationStore,
|
||||
conversations: conversations,
|
||||
locationChannelsModel: locationChannelsModel
|
||||
)
|
||||
let verificationModel = VerificationModel(
|
||||
@@ -72,11 +72,11 @@ private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatur
|
||||
let conversationUIModel = ConversationUIModel(
|
||||
chatViewModel: viewModel,
|
||||
privateConversationModel: privateConversationModel,
|
||||
conversationStore: conversationStore
|
||||
conversations: conversations
|
||||
)
|
||||
let peerListModel = PeerListModel(
|
||||
chatViewModel: viewModel,
|
||||
conversationStore: conversationStore,
|
||||
conversations: conversations,
|
||||
locationChannelsModel: locationChannelsModel
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user