mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 12:05:19 +00:00
Route delivery status through the store; delete the location index
ConversationStore maintains an exact messageID -> Set<ConversationID> map at every mutation point (append/upsert/remove/migrate/trim/clear), so delivery updates are ID-only lookups that fan out to mirrored ephemeral/stable copies. ChatDeliveryCoordinator shrinks 327 -> 119 lines: the positional location index, its growth-detection/rebuild machinery, and the duplicate no-downgrade check are deleted - the rule now lives in exactly one place. The middle-insertion regression tests are rewritten against the store since stale positional locations are structurally impossible now. delivery updates: 38k -> 262k/s (~6.9x); ingest pipelines unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -64,10 +64,25 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
return messages[index]
|
||||
}
|
||||
|
||||
/// All message IDs currently in this conversation (unordered).
|
||||
var messageIDs: Dictionary<String, Int>.Keys {
|
||||
indexByMessageID.keys
|
||||
}
|
||||
|
||||
// MARK: Store-internal mutations
|
||||
|
||||
/// Result of an ordered insert. `trimmedMessageIDs` reports messages
|
||||
/// evicted by the cap so the store can keep its message-ID →
|
||||
/// conversation map exact.
|
||||
fileprivate struct InsertResult {
|
||||
let inserted: Bool
|
||||
let trimmedMessageIDs: [String]
|
||||
|
||||
static let duplicate = InsertResult(inserted: false, trimmedMessageIDs: [])
|
||||
}
|
||||
|
||||
fileprivate enum UpsertOutcome {
|
||||
case appended
|
||||
case appended(trimmedMessageIDs: [String])
|
||||
case updated
|
||||
}
|
||||
|
||||
@@ -75,9 +90,9 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
/// Fast path appends when the timestamp is >= the current tail;
|
||||
/// otherwise a binary search finds the upper-bound insertion point so
|
||||
/// arrival order is preserved among equal timestamps.
|
||||
/// Returns `false` if a message with the same ID already exists.
|
||||
fileprivate func insert(_ message: BitchatMessage) -> Bool {
|
||||
guard indexByMessageID[message.id] == nil else { return false }
|
||||
/// Reports `inserted: false` if a message with the same ID already exists.
|
||||
fileprivate func insert(_ message: BitchatMessage) -> InsertResult {
|
||||
guard indexByMessageID[message.id] == nil else { return .duplicate }
|
||||
|
||||
if let last = messages.last, message.timestamp < last.timestamp {
|
||||
let index = insertionIndex(for: message.timestamp)
|
||||
@@ -88,8 +103,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
indexByMessageID[message.id] = messages.count - 1
|
||||
}
|
||||
|
||||
trimIfNeeded()
|
||||
return true
|
||||
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
|
||||
}
|
||||
|
||||
/// Replace-or-append by message ID. An existing message keeps its
|
||||
@@ -100,14 +114,14 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
messages[index] = message
|
||||
return .updated
|
||||
}
|
||||
_ = insert(message)
|
||||
return .appended
|
||||
let result = insert(message)
|
||||
return .appended(trimmedMessageIDs: result.trimmedMessageIDs)
|
||||
}
|
||||
|
||||
/// Applies a delivery status keyed by message ID, honoring the
|
||||
/// no-downgrade rule (mirrors `ChatDeliveryCoordinator.shouldSkipUpdate`):
|
||||
/// equal statuses are skipped, and `.read` is never downgraded to
|
||||
/// `.delivered` or `.sent`.
|
||||
/// no-downgrade rule (the SOLE enforcement point — every delivery
|
||||
/// update flows through the store): equal statuses are skipped, and
|
||||
/// `.read` is never downgraded to `.delivered` or `.sent`.
|
||||
/// Returns `true` when the status was applied.
|
||||
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||
guard let index = indexByMessageID[messageID] else { return false }
|
||||
@@ -197,14 +211,17 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
private func trimIfNeeded() {
|
||||
guard messages.count > cap else { return }
|
||||
/// Trims oldest messages over the cap; returns the trimmed message IDs.
|
||||
private func trimIfNeeded() -> [String] {
|
||||
guard messages.count > cap else { return [] }
|
||||
let overflow = messages.count - cap
|
||||
for message in messages.prefix(overflow) {
|
||||
indexByMessageID.removeValue(forKey: message.id)
|
||||
let trimmedIDs = messages.prefix(overflow).map(\.id)
|
||||
for id in trimmedIDs {
|
||||
indexByMessageID.removeValue(forKey: id)
|
||||
}
|
||||
messages.removeFirst(overflow)
|
||||
reindex(from: 0)
|
||||
return trimmedIDs
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +259,19 @@ final class ConversationStore: ObservableObject {
|
||||
|
||||
private(set) var conversationsByID: [ConversationID: Conversation] = [:]
|
||||
|
||||
/// Store-level message-ID → conversation-membership map for ID-only
|
||||
/// lookups (delivery receipts arrive with a message ID, not a
|
||||
/// conversation). Maintained incrementally at every mutation point —
|
||||
/// all mutation is centralized in the intent API below, so the map is
|
||||
/// exact, never scanned or rebuilt.
|
||||
///
|
||||
/// The value is a `Set` because a private message can legitimately live
|
||||
/// in TWO direct conversations: step 2's raw per-peer keying mirrors a
|
||||
/// message into both the stable-key and ephemeral-peer chats
|
||||
/// (`mirrorToEphemeralIfNeeded`). A delivery update must reach both
|
||||
/// copies.
|
||||
private var conversationIDsByMessageID: [String: Set<ConversationID>] = [:]
|
||||
|
||||
let changes = PassthroughSubject<ConversationChange, Never>()
|
||||
|
||||
// MARK: Intent API
|
||||
@@ -264,7 +294,10 @@ final class ConversationStore: ObservableObject {
|
||||
@discardableResult
|
||||
func append(_ message: BitchatMessage, to id: ConversationID) -> Bool {
|
||||
let conversation = conversation(for: id)
|
||||
guard conversation.insert(message) else { return false }
|
||||
let result = conversation.insert(message)
|
||||
guard result.inserted else { return false }
|
||||
registerMessageID(message.id, in: id)
|
||||
unregisterMessageIDs(result.trimmedMessageIDs, from: id)
|
||||
changes.send(.appended(id, message))
|
||||
return true
|
||||
}
|
||||
@@ -273,7 +306,9 @@ final class ConversationStore: ObservableObject {
|
||||
func upsertByID(_ message: BitchatMessage, in id: ConversationID) {
|
||||
let conversation = conversation(for: id)
|
||||
switch conversation.upsert(message) {
|
||||
case .appended:
|
||||
case .appended(let trimmedMessageIDs):
|
||||
registerMessageID(message.id, in: id)
|
||||
unregisterMessageIDs(trimmedMessageIDs, from: id)
|
||||
changes.send(.appended(id, message))
|
||||
case .updated:
|
||||
changes.send(.updated(id, messageID: message.id))
|
||||
@@ -293,6 +328,44 @@ final class ConversationStore: ObservableObject {
|
||||
return true
|
||||
}
|
||||
|
||||
/// Applies a delivery status to EVERY conversation containing
|
||||
/// `messageID` (ID-only — delivery receipts don't know conversations;
|
||||
/// mirrored private copies live in two direct chats). Returns `false`
|
||||
/// when the message is unknown or no copy changed (equal status or
|
||||
/// downgrade — read beats delivered beats sent).
|
||||
///
|
||||
/// `BitchatMessage` is a reference type, so mirrored copies sharing one
|
||||
/// instance are updated by the first apply; subsequent conversations
|
||||
/// skip as already-equal (state stays correct everywhere, the
|
||||
/// `.statusChanged` event fires for the conversation that applied).
|
||||
@discardableResult
|
||||
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||
guard let ids = conversationIDsByMessageID[messageID] else { return false }
|
||||
var applied = false
|
||||
for id in ids where setDeliveryStatus(status, forMessageID: messageID, in: id) {
|
||||
applied = true
|
||||
}
|
||||
return applied
|
||||
}
|
||||
|
||||
/// Current delivery status of `messageID` in whichever conversation
|
||||
/// holds it (mirrored copies share status — see `setDeliveryStatus`).
|
||||
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
|
||||
guard let ids = conversationIDsByMessageID[messageID] else { return nil }
|
||||
for id in ids {
|
||||
if let status = conversationsByID[id]?.message(withID: messageID)?.deliveryStatus {
|
||||
return status
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Every conversation currently containing `messageID` (empty when the
|
||||
/// message is unknown).
|
||||
func conversationIDs(forMessageID messageID: String) -> Set<ConversationID> {
|
||||
conversationIDsByMessageID[messageID] ?? []
|
||||
}
|
||||
|
||||
func markRead(_ id: ConversationID) {
|
||||
guard unreadConversations.contains(id) else { return }
|
||||
unreadConversations.remove(id)
|
||||
@@ -329,7 +402,13 @@ final class ConversationStore: ObservableObject {
|
||||
|
||||
let destinationConversation = conversation(for: destination)
|
||||
for message in sourceConversation.messages {
|
||||
_ = destinationConversation.insert(message)
|
||||
let result = destinationConversation.insert(message)
|
||||
guard result.inserted else { continue }
|
||||
registerMessageID(message.id, in: destination)
|
||||
unregisterMessageIDs(result.trimmedMessageIDs, from: destination)
|
||||
}
|
||||
for messageID in sourceConversation.messageIDs {
|
||||
unregisterMessageID(messageID, from: source)
|
||||
}
|
||||
|
||||
let wasUnread = unreadConversations.contains(source)
|
||||
@@ -359,6 +438,7 @@ final class ConversationStore: ObservableObject {
|
||||
let removed = conversation.remove(messageID: messageID) else {
|
||||
return nil
|
||||
}
|
||||
unregisterMessageID(messageID, from: id)
|
||||
changes.send(.messageRemoved(id, messageID: messageID))
|
||||
return removed
|
||||
}
|
||||
@@ -368,7 +448,9 @@ final class ConversationStore: ObservableObject {
|
||||
/// conversation is consistent. No-op for unknown conversations.
|
||||
func removeMessages(from id: ConversationID, where predicate: (BitchatMessage) -> Bool) {
|
||||
guard let conversation = conversationsByID[id] else { return }
|
||||
for messageID in conversation.removeAll(where: predicate) {
|
||||
let removedIDs = conversation.removeAll(where: predicate)
|
||||
unregisterMessageIDs(removedIDs, from: id)
|
||||
for messageID in removedIDs {
|
||||
changes.send(.messageRemoved(id, messageID: messageID))
|
||||
}
|
||||
}
|
||||
@@ -377,6 +459,9 @@ final class ConversationStore: ObservableObject {
|
||||
/// its unread/selection state) alive.
|
||||
func clear(_ id: ConversationID) {
|
||||
guard let conversation = conversationsByID[id] else { return }
|
||||
for messageID in conversation.messageIDs {
|
||||
unregisterMessageID(messageID, from: id)
|
||||
}
|
||||
conversation.clearMessages()
|
||||
changes.send(.cleared(id))
|
||||
}
|
||||
@@ -384,7 +469,10 @@ final class ConversationStore: ObservableObject {
|
||||
/// Removes a conversation entirely, including unread state; clears the
|
||||
/// selection if it pointed at the removed conversation.
|
||||
func removeConversation(_ id: ConversationID) {
|
||||
guard conversationsByID.removeValue(forKey: id) != nil else { return }
|
||||
guard let conversation = conversationsByID.removeValue(forKey: id) else { return }
|
||||
for messageID in conversation.messageIDs {
|
||||
unregisterMessageID(messageID, from: id)
|
||||
}
|
||||
conversationIDs.removeAll { $0 == id }
|
||||
unreadConversations.remove(id)
|
||||
if selectedConversationID == id {
|
||||
@@ -400,6 +488,7 @@ final class ConversationStore: ObservableObject {
|
||||
conversationsByID.removeAll()
|
||||
conversationIDs.removeAll()
|
||||
unreadConversations.removeAll()
|
||||
conversationIDsByMessageID.removeAll()
|
||||
if selectedConversationID != nil {
|
||||
selectedConversationID = nil
|
||||
}
|
||||
@@ -411,6 +500,26 @@ final class ConversationStore: ObservableObject {
|
||||
|
||||
// MARK: Internals
|
||||
|
||||
private func registerMessageID(_ messageID: String, in id: ConversationID) {
|
||||
conversationIDsByMessageID[messageID, default: []].insert(id)
|
||||
}
|
||||
|
||||
private func unregisterMessageID(_ messageID: String, from id: ConversationID) {
|
||||
guard var ids = conversationIDsByMessageID[messageID] else { return }
|
||||
ids.remove(id)
|
||||
if ids.isEmpty {
|
||||
conversationIDsByMessageID.removeValue(forKey: messageID)
|
||||
} else {
|
||||
conversationIDsByMessageID[messageID] = ids
|
||||
}
|
||||
}
|
||||
|
||||
private func unregisterMessageIDs(_ messageIDs: [String], from id: ConversationID) {
|
||||
for messageID in messageIDs {
|
||||
unregisterMessageID(messageID, from: id)
|
||||
}
|
||||
}
|
||||
|
||||
private static func cap(for id: ConversationID) -> Int {
|
||||
switch id {
|
||||
case .mesh:
|
||||
@@ -471,13 +580,23 @@ extension ConversationStore {
|
||||
}
|
||||
|
||||
/// `true` when any direct conversation contains a message with `messageID`
|
||||
/// (O(1) per conversation via the incremental ID index).
|
||||
/// (O(1) via the store-level message-ID → conversation map).
|
||||
func directConversationsContainMessage(withID messageID: String) -> Bool {
|
||||
conversationIDs(forMessageID: messageID).contains { id in
|
||||
if case .direct = id { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Message IDs across all direct conversations (read-receipt pruning
|
||||
/// keeps only receipts whose messages still exist).
|
||||
func directMessageIDs() -> Set<String> {
|
||||
var messageIDs = Set<String>()
|
||||
for (id, conversation) in conversationsByID {
|
||||
guard case .direct = id else { continue }
|
||||
if conversation.containsMessage(withID: messageID) { return true }
|
||||
messageIDs.formUnion(conversation.messageIDs)
|
||||
}
|
||||
return false
|
||||
return messageIDs
|
||||
}
|
||||
|
||||
/// Removes every direct conversation (panic clear).
|
||||
@@ -501,12 +620,10 @@ extension ConversationStore {
|
||||
/// message, if any.
|
||||
@discardableResult
|
||||
func removePublicMessage(withID messageID: String) -> BitchatMessage? {
|
||||
for (id, conversation) in conversationsByID {
|
||||
for id in conversationIDs(forMessageID: messageID) {
|
||||
switch id {
|
||||
case .mesh, .geohash:
|
||||
if conversation.containsMessage(withID: messageID) {
|
||||
return removeMessage(withID: messageID, from: id)
|
||||
}
|
||||
return removeMessage(withID: messageID, from: id)
|
||||
case .direct:
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -12,18 +12,19 @@ import Foundation
|
||||
/// coordinators off their `unowned let viewModel: ChatViewModel` back-refs.
|
||||
@MainActor
|
||||
protocol ChatDeliveryContext: AnyObject {
|
||||
/// Get-only derived views of the `ConversationStore`. `BitchatMessage`
|
||||
/// is a reference type, so the public-timeline status patch below writes
|
||||
/// through the shared message objects; step 4 replaces this with
|
||||
/// `setDeliveryStatus(for:in:)` store intents.
|
||||
var messages: [BitchatMessage] { get }
|
||||
var privateChats: [PeerID: [BitchatMessage]] { get }
|
||||
var isStartupPhase: Bool { get }
|
||||
/// Applies a delivery status to a private message by ID (single-writer
|
||||
/// store intent; full delivery migration is step 4). Returns `false`
|
||||
/// when the message is unknown or the update would downgrade the status.
|
||||
/// Applies a delivery status to every copy of the message across
|
||||
/// conversations (`ConversationStore` intent, ID-only: the store's
|
||||
/// message-ID → conversation map resolves which conversations hold the
|
||||
/// message, including mirrored ephemeral/stable private copies). The
|
||||
/// no-downgrade rule is enforced in the store. Returns `false` when the
|
||||
/// message is unknown or no copy changed.
|
||||
@discardableResult
|
||||
func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool
|
||||
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool
|
||||
/// Current delivery status of the message in whichever conversation holds it.
|
||||
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus?
|
||||
/// Message IDs across all direct conversations (read-receipt pruning).
|
||||
func privateMessageIDs() -> Set<String>
|
||||
/// Drops every recorded read receipt whose message ID is not in `validMessageIDs`.
|
||||
/// Returns the number of receipts removed. (Single mutation path for the
|
||||
/// owner's `sentReadReceipts`; this coordinator never reads the raw set.)
|
||||
@@ -35,6 +36,19 @@ protocol ChatDeliveryContext: AnyObject {
|
||||
}
|
||||
|
||||
extension ChatViewModel: ChatDeliveryContext {
|
||||
@discardableResult
|
||||
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||
conversations.setDeliveryStatus(status, forMessageID: messageID)
|
||||
}
|
||||
|
||||
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
|
||||
conversations.deliveryStatus(forMessageID: messageID)
|
||||
}
|
||||
|
||||
func privateMessageIDs() -> Set<String> {
|
||||
conversations.directMessageIDs()
|
||||
}
|
||||
|
||||
func notifyUIChanged() {
|
||||
objectWillChange.send()
|
||||
}
|
||||
@@ -44,14 +58,12 @@ extension ChatViewModel: ChatDeliveryContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// Thin mapper from delivery events (read receipts, transport delivery
|
||||
/// callbacks) onto `ConversationStore` delivery intents, plus read-receipt
|
||||
/// retention cleanup. The store's message-ID → conversation map replaces the
|
||||
/// positional `messageLocationIndex` this coordinator used to maintain.
|
||||
final class ChatDeliveryCoordinator {
|
||||
private unowned let context: any ChatDeliveryContext
|
||||
private var messageLocationIndex: [String: Set<MessageLocation>] = [:]
|
||||
private var indexedPublicMessageCount = 0
|
||||
private var indexedPublicTailMessageID: String?
|
||||
private var indexedPrivateMessageCounts: [PeerID: Int] = [:]
|
||||
private var indexedPrivateTailMessageIDs: [PeerID: String] = [:]
|
||||
private var hasBuiltMessageLocationIndex = false
|
||||
|
||||
init(context: any ChatDeliveryContext) {
|
||||
self.context = context
|
||||
@@ -59,15 +71,9 @@ final class ChatDeliveryCoordinator {
|
||||
|
||||
@MainActor
|
||||
func cleanupOldReadReceipts() {
|
||||
guard !context.isStartupPhase, !context.privateChats.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
let validMessageIDs = Set(
|
||||
context.privateChats.values.flatMap { messages in
|
||||
messages.map(\.id)
|
||||
}
|
||||
)
|
||||
guard !context.isStartupPhase else { return }
|
||||
let validMessageIDs = context.privateMessageIDs()
|
||||
guard !validMessageIDs.isEmpty else { return }
|
||||
|
||||
let removedCount = context.pruneSentReadReceipts(keeping: validMessageIDs)
|
||||
if removedCount > 0 {
|
||||
@@ -90,9 +96,7 @@ final class ChatDeliveryCoordinator {
|
||||
|
||||
@MainActor
|
||||
func deliveryStatus(for messageID: String) -> DeliveryStatus? {
|
||||
withValidLocations(for: messageID) { locations in
|
||||
locations.lazy.compactMap { self.deliveryStatus(at: $0) }.first
|
||||
}
|
||||
context.deliveryStatus(forMessageID: messageID)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -106,222 +110,10 @@ final class ChatDeliveryCoordinator {
|
||||
break
|
||||
}
|
||||
|
||||
var didUpdateStatus = false
|
||||
let locations = withValidLocations(for: messageID) { $0 }
|
||||
guard !locations.isEmpty else { return false }
|
||||
|
||||
for location in locations {
|
||||
guard case .publicTimeline(let index) = location,
|
||||
index < context.messages.count,
|
||||
context.messages[index].id == messageID else {
|
||||
continue
|
||||
}
|
||||
|
||||
let currentStatus = context.messages[index].deliveryStatus
|
||||
if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) {
|
||||
context.messages[index].deliveryStatus = status
|
||||
didUpdateStatus = true
|
||||
}
|
||||
}
|
||||
|
||||
for location in locations {
|
||||
guard case .privateChat(let peerID, let index) = location,
|
||||
let chatMessages = context.privateChats[peerID],
|
||||
index < chatMessages.count,
|
||||
chatMessages[index].id == messageID else {
|
||||
continue
|
||||
}
|
||||
|
||||
let currentStatus = chatMessages[index].deliveryStatus
|
||||
guard !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) else { continue }
|
||||
|
||||
if context.setPrivateDeliveryStatus(status, forMessageID: messageID, peerID: peerID) {
|
||||
didUpdateStatus = true
|
||||
}
|
||||
}
|
||||
|
||||
if didUpdateStatus {
|
||||
context.notifyUIChanged()
|
||||
}
|
||||
|
||||
return didUpdateStatus
|
||||
}
|
||||
}
|
||||
|
||||
private extension ChatDeliveryCoordinator {
|
||||
enum MessageLocation: Hashable {
|
||||
case publicTimeline(index: Int)
|
||||
case privateChat(peerID: PeerID, index: Int)
|
||||
}
|
||||
|
||||
func shouldSkipUpdate(currentStatus: DeliveryStatus?, newStatus: DeliveryStatus) -> Bool {
|
||||
guard let currentStatus else { return false }
|
||||
if currentStatus == newStatus { return true }
|
||||
|
||||
switch (currentStatus, newStatus) {
|
||||
case (.read, .delivered), (.read, .sent):
|
||||
return true
|
||||
default:
|
||||
guard context.setDeliveryStatus(status, forMessageID: messageID) else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func withValidLocations<T>(
|
||||
for messageID: String,
|
||||
_ body: (Set<MessageLocation>) -> T
|
||||
) -> T {
|
||||
let didRebuildIndex = refreshMessageLocationIndexForGrowth()
|
||||
|
||||
if let locations = messageLocationIndex[messageID],
|
||||
locations.allSatisfy({ isLocation($0, validFor: messageID) }) {
|
||||
return body(locations)
|
||||
}
|
||||
|
||||
guard !didRebuildIndex else {
|
||||
return body(messageLocationIndex[messageID] ?? [])
|
||||
}
|
||||
|
||||
if messageLocationIndex[messageID] == nil {
|
||||
return body([])
|
||||
}
|
||||
|
||||
rebuildMessageLocationIndex()
|
||||
return body(messageLocationIndex[messageID] ?? [])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func deliveryStatus(at location: MessageLocation) -> DeliveryStatus? {
|
||||
switch location {
|
||||
case .publicTimeline(let index):
|
||||
guard index < context.messages.count else { return nil }
|
||||
return context.messages[index].deliveryStatus
|
||||
case .privateChat(let peerID, let index):
|
||||
guard let messages = context.privateChats[peerID],
|
||||
index < messages.count else {
|
||||
return nil
|
||||
}
|
||||
return messages[index].deliveryStatus
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func isLocation(_ location: MessageLocation, validFor messageID: String) -> Bool {
|
||||
switch location {
|
||||
case .publicTimeline(let index):
|
||||
return index < context.messages.count
|
||||
&& context.messages[index].id == messageID
|
||||
case .privateChat(let peerID, let index):
|
||||
guard let messages = context.privateChats[peerID],
|
||||
index < messages.count else {
|
||||
return false
|
||||
}
|
||||
return messages[index].id == messageID
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@discardableResult
|
||||
func refreshMessageLocationIndexForGrowth() -> Bool {
|
||||
guard hasBuiltMessageLocationIndex else {
|
||||
rebuildMessageLocationIndex()
|
||||
return true
|
||||
}
|
||||
|
||||
if context.messages.count < indexedPublicMessageCount {
|
||||
rebuildMessageLocationIndex()
|
||||
return true
|
||||
}
|
||||
|
||||
if context.messages.count == indexedPublicMessageCount,
|
||||
context.messages.last?.id != indexedPublicTailMessageID {
|
||||
rebuildMessageLocationIndex()
|
||||
return true
|
||||
}
|
||||
|
||||
if context.messages.count > indexedPublicMessageCount {
|
||||
// Growth is only a pure append if the previously indexed tail kept
|
||||
// its position; a middle insertion (out-of-order timestamp arrival)
|
||||
// shifts it and invalidates every indexed location after the
|
||||
// insertion point.
|
||||
if indexedPublicMessageCount > 0,
|
||||
context.messages[indexedPublicMessageCount - 1].id != indexedPublicTailMessageID {
|
||||
rebuildMessageLocationIndex()
|
||||
return true
|
||||
}
|
||||
for index in indexedPublicMessageCount..<context.messages.count {
|
||||
add(.publicTimeline(index: index), for: context.messages[index].id)
|
||||
}
|
||||
indexedPublicMessageCount = context.messages.count
|
||||
indexedPublicTailMessageID = context.messages.last?.id
|
||||
}
|
||||
|
||||
let currentPeerIDs = Set(context.privateChats.keys)
|
||||
if !Set(indexedPrivateMessageCounts.keys).isSubset(of: currentPeerIDs) {
|
||||
rebuildMessageLocationIndex()
|
||||
return true
|
||||
}
|
||||
|
||||
for (peerID, messages) in context.privateChats {
|
||||
let indexedCount = indexedPrivateMessageCounts[peerID] ?? 0
|
||||
if messages.count < indexedCount {
|
||||
rebuildMessageLocationIndex()
|
||||
return true
|
||||
}
|
||||
|
||||
if messages.count == indexedCount,
|
||||
messages.last?.id != indexedPrivateTailMessageIDs[peerID] {
|
||||
rebuildMessageLocationIndex()
|
||||
return true
|
||||
}
|
||||
|
||||
guard messages.count > indexedCount else { continue }
|
||||
// Same append-only check as the public timeline above.
|
||||
if indexedCount > 0,
|
||||
messages[indexedCount - 1].id != indexedPrivateTailMessageIDs[peerID] {
|
||||
rebuildMessageLocationIndex()
|
||||
return true
|
||||
}
|
||||
for index in indexedCount..<messages.count {
|
||||
add(.privateChat(peerID: peerID, index: index), for: messages[index].id)
|
||||
}
|
||||
indexedPrivateMessageCounts[peerID] = messages.count
|
||||
if let tailID = messages.last?.id {
|
||||
indexedPrivateTailMessageIDs[peerID] = tailID
|
||||
} else {
|
||||
indexedPrivateTailMessageIDs.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func rebuildMessageLocationIndex() {
|
||||
messageLocationIndex.removeAll(keepingCapacity: true)
|
||||
|
||||
for (index, message) in context.messages.enumerated() {
|
||||
add(.publicTimeline(index: index), for: message.id)
|
||||
}
|
||||
indexedPublicMessageCount = context.messages.count
|
||||
indexedPublicTailMessageID = context.messages.last?.id
|
||||
|
||||
indexedPrivateMessageCounts.removeAll(keepingCapacity: true)
|
||||
indexedPrivateTailMessageIDs.removeAll(keepingCapacity: true)
|
||||
for (peerID, messages) in context.privateChats {
|
||||
for (index, message) in messages.enumerated() {
|
||||
add(.privateChat(peerID: peerID, index: index), for: message.id)
|
||||
}
|
||||
indexedPrivateMessageCounts[peerID] = messages.count
|
||||
if let tailID = messages.last?.id {
|
||||
indexedPrivateTailMessageIDs[peerID] = tailID
|
||||
}
|
||||
}
|
||||
|
||||
hasBuiltMessageLocationIndex = true
|
||||
}
|
||||
|
||||
func add(_ location: MessageLocation, for messageID: String) {
|
||||
messageLocationIndex[messageID, default: []].insert(location)
|
||||
context.notifyUIChanged()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user