Files
Morgan Pretty 69c242fae6 Bunch of bug fixes
• Removed test pro sub id
• Fixed a few font sizing bugs in the conversation list and search results
• Fixed an issue where the conversation settings wouldn't respond to some conversation update events
• Fixed an issue where the conversation screen wouldn't respond to some conversation update events
• Fixed an issue where a user that had their name set to their id wouldn't render a truncated id
• Fixed an issue where a group admin couldn't "Clear All Messages" just on the current device
• Fixed an issue where clearing messages in a conversation wouldn't remove the messages from the UI until you left and returned to the screen
• Fixed an issue where the home screen wouldn't update it's ordering when a message was deleted
• Fixed an issue where the home screen wouldn't update it's unread counts when all messages in a conversation were removed
• Fixed an issue where conversation snippets on the home screen incorrectly showed formatting
2025-12-12 16:18:03 +11:00

401 lines
15 KiB
Swift

// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUIKit
import SessionUtilitiesKit
public typealias ConversationDataCacheItemRequirements = (Sendable & Equatable & Hashable & Identifiable)
public struct ConversationDataCache: Sendable, Equatable, Hashable {
public let userSessionId: SessionId
public fileprivate(set) var context: Context
// MARK: - General
/// Stores `profileId -> Profile` (`threadId` for contact threads)
public fileprivate(set) var profiles: [String: Profile] = [:]
// MARK: - Thread Data
/// Stores `threadId -> SessionThread`
public fileprivate(set) var threads: [String: SessionThread] = [:]
/// Stores `contactId -> Contact` (`threadId` for contact threads)
public fileprivate(set) var contacts: [String: Contact] = [:]
/// Stores `threadId -> ClosedGroup`
public fileprivate(set) var groups: [String: ClosedGroup] = [:]
/// Stores `threadId -> GroupInfo`
public fileprivate(set) var groupInfo: [String: LibSession.GroupInfo] = [:]
/// Stores `threadId -> members`
public fileprivate(set) var groupMembers: [String: [GroupMember]] = [:]
/// Stores `threadId -> OpenGroup`
public fileprivate(set) var communities: [String: OpenGroup] = [:]
/// Stores `openGroup.server -> capabilityVariants`
public fileprivate(set) var communityCapabilities: [String: Set<Capability.Variant>] = [:]
/// Stores `threadId -> modAdminIds`
public fileprivate(set) var communityModAdminIds: [String: Set<String>] = [:]
/// Stores `threadId -> isUserModeratorOrAdmin`
public fileprivate(set) var userModeratorOrAdmin: [String: Bool] = [:]
/// Stores `threadId -> DisappearingMessagesConfig`
public fileprivate(set) var disappearingMessagesConfigurations: [String: DisappearingMessagesConfiguration] = [:]
/// Stores `threadId -> interactionStats`
public fileprivate(set) var interactionStats: [String: ConversationInfoViewModel.InteractionStats] = [:]
/// Stores `threadId -> currentUserSessionIds`
public fileprivate(set) var currentUserSessionIds: [String: Set<String>] = [:]
// MARK: - Message Data
/// Stores `interactionId -> Interaction`
public fileprivate(set) var interactions: [Int64: Interaction] = [:]
/// Stores `interactionId -> interactionAttachments`
public fileprivate(set) var attachmentMap: [Int64: Set<InteractionAttachment>] = [:]
/// Stores `attachmentId -> Attachment`
public fileprivate(set) var attachments: [String: Attachment] = [:]
/// Stores `interactionId -> MaybeUnresolvedQuotedInfo`
public fileprivate(set) var quoteMap: [Int64: MessageViewModel.MaybeUnresolvedQuotedInfo] = [:]
/// Stores `url -> previews`
public fileprivate(set) var linkPreviews: [String: Set<LinkPreview>] = [:]
/// Stores `interactionId -> reactions`
public fileprivate(set) var reactions: [Int64: [Reaction]] = [:]
/// Stores `blindedId -> unblindedId`
public fileprivate(set) var unblindedIdMap: [String: String] = [:]
// MARK: - UI State
/// Stores `threadIds` for conversations with incoming typing
public fileprivate(set) var incomingTyping: Set<String> = []
// MARK: - Initialization
public init(userSessionId: SessionId, context: Context) {
self.userSessionId = userSessionId
self.context = context
}
}
// MARK: - Read Operations
public extension ConversationDataCache {
func profile(for id: String) -> Profile? { profiles[id] }
func thread(for id: String) -> SessionThread? { threads[id] }
func contact(for threadId: String) -> Contact? { contacts[threadId] }
func group(for threadId: String) -> ClosedGroup? { groups[threadId] }
func groupInfo(for threadId: String) -> LibSession.GroupInfo? { groupInfo[threadId] }
func groupMembers(for threadId: String) -> [GroupMember] { (groupMembers[threadId] ?? []) }
func community(for threadId: String) -> OpenGroup? { communities[threadId] }
func communityCapabilities(for server: String) -> Set<Capability.Variant> {
(communityCapabilities[server] ?? [])
}
func communityModAdminIds(for threadId: String) -> Set<String> { (communityModAdminIds[threadId] ?? []) }
func isUserModeratorOrAdmin(in threadId: String) -> Bool { (userModeratorOrAdmin[threadId] ?? false) }
func disappearingMessageConfiguration(for threadId: String) -> DisappearingMessagesConfiguration? {
disappearingMessagesConfigurations[threadId]
}
func interactionStats(for threadId: String) -> ConversationInfoViewModel.InteractionStats? {
interactionStats[threadId]
}
func currentUserSessionIds(for threadId: String) -> Set<String> {
return (currentUserSessionIds[threadId] ?? [userSessionId.hexString])
}
func interaction(for id: Int64) -> Interaction? { interactions[id] }
func attachment(for id: String) -> Attachment? { attachments[id] }
func attachments(for interactionId: Int64) -> [Attachment] {
guard let interactionAttachments: Set<InteractionAttachment> = attachmentMap[interactionId] else {
return []
}
return interactionAttachments
.sorted { $0.albumIndex < $1.albumIndex }
.compactMap { attachments[$0.attachmentId] }
}
func interactionAttachments(for interactionId: Int64) -> Set<InteractionAttachment> {
(attachmentMap[interactionId] ?? [])
}
func quoteInfo(for interactionId: Int64) -> MessageViewModel.MaybeUnresolvedQuotedInfo? {
quoteMap[interactionId]
}
func linkPreviews(for url: String) -> Set<LinkPreview> { (linkPreviews[url] ?? []) }
func reactions(for interactionId: Int64) -> [Reaction] { (reactions[interactionId] ?? []) }
func unblindedId(for blindedId: String) -> String? { unblindedIdMap[blindedId] }
func isTyping(in threadId: String) -> Bool { incomingTyping.contains(threadId) }
func displayNameRetriever(for threadId: String, includeSessionIdSuffixWhenInMessageBody: Bool) -> DisplayNameRetriever {
let currentUserSessionIds: Set<String> = currentUserSessionIds(for: threadId)
return { sessionId, inMessageBody in
guard !currentUserSessionIds.contains(sessionId) else {
return "you".localized()
}
return profile(for: sessionId)?.displayName(
includeSessionIdSuffix: (includeSessionIdSuffixWhenInMessageBody && inMessageBody)
)
}
}
}
// MARK: - Write Operations
public extension ConversationDataCache {
mutating func withContext(
source: Context.Source,
requireFullRefresh: Bool = false,
requireAuthMethodFetch: Bool = false,
requiresMessageRequestCountUpdate: Bool = false,
requiresInitialUnreadInteractionInfo: Bool = false,
requireRecentReactionEmojiUpdate: Bool = false
) {
self.context = Context(
source: source,
requireFullRefresh: requireFullRefresh,
requireAuthMethodFetch: requireAuthMethodFetch,
requiresMessageRequestCountUpdate: requiresMessageRequestCountUpdate,
requiresInitialUnreadInteractionInfo: requiresInitialUnreadInteractionInfo,
requireRecentReactionEmojiUpdate: requireRecentReactionEmojiUpdate
)
}
mutating func insert(_ profile: Profile) {
self.profiles[profile.id] = profile
}
mutating func insert(profiles: [Profile]) {
profiles.forEach { self.profiles[$0.id] = $0 }
}
mutating func insert(_ thread: SessionThread) {
self.threads[thread.id] = thread
}
mutating func insert(threads: [SessionThread]) {
threads.forEach { self.threads[$0.id] = $0 }
}
mutating func insert(_ contact: Contact) {
self.contacts[contact.id] = contact
}
mutating func insert(contacts: [Contact]) {
contacts.forEach { self.contacts[$0.id] = $0 }
}
mutating func insert(_ group: ClosedGroup) {
self.groups[group.threadId] = group
}
mutating func insert(groups: [ClosedGroup]) {
groups.forEach { self.groups[$0.threadId] = $0 }
}
mutating func insert(_ groupInfo: LibSession.GroupInfo) {
self.groupInfo[groupInfo.groupSessionId] = groupInfo
}
mutating func insert(groupInfo: [LibSession.GroupInfo]) {
groupInfo.forEach { self.groupInfo[$0.groupSessionId] = $0 }
}
mutating func insert(groupMembers: [String: [GroupMember]]) {
self.groupMembers.merge(groupMembers) { _, new in new }
}
mutating func insert(_ community: OpenGroup) {
self.communities[community.threadId] = community
}
mutating func insert(communities: [OpenGroup]) {
communities.forEach { self.communities[$0.threadId] = $0 }
}
mutating func insert(communityCapabilities: [String: Set<Capability.Variant>]) {
self.communityCapabilities.merge(communityCapabilities) { _, new in new }
}
mutating func insert(communityModAdminIds: [String: Set<String>]) {
self.communityModAdminIds.merge(communityModAdminIds) { _, new in new }
}
mutating func insert(isUserModeratorOrAdmin: Bool, in threadId: String) {
self.userModeratorOrAdmin[threadId] = isUserModeratorOrAdmin
}
mutating func insert(_ config: DisappearingMessagesConfiguration) {
self.disappearingMessagesConfigurations[config.threadId] = config
}
mutating func insert(disappearingMessagesConfigurations configs: [DisappearingMessagesConfiguration]) {
configs.forEach { self.disappearingMessagesConfigurations[$0.threadId] = $0 }
}
mutating func insert(_ stats: ConversationInfoViewModel.InteractionStats) {
self.interactionStats[stats.threadId] = stats
}
mutating func insert(interactionStats: [ConversationInfoViewModel.InteractionStats]) {
interactionStats.forEach { self.interactionStats[$0.threadId] = $0 }
}
mutating func setCurrentUserSessionIds(_ currentUserSessionIds: [String: Set<String>]) {
self.currentUserSessionIds = currentUserSessionIds
}
mutating func insert(_ interaction: Interaction) {
guard let id: Int64 = interaction.id else { return }
self.interactions[id] = interaction
}
mutating func insert(interactions: [Interaction]) {
interactions.forEach { interaction in
guard let id: Int64 = interaction.id else { return }
self.interactions[id] = interaction
}
}
mutating func insert(_ attachment: Attachment) {
self.attachments[attachment.id] = attachment
}
mutating func insert(attachments: [Attachment]) {
attachments.forEach { self.attachments[$0.id] = $0 }
}
mutating func insert(attachmentMap: [Int64: Set<InteractionAttachment>]) {
self.attachmentMap.merge(attachmentMap) { _, new in new }
/// Remove any empty lists
attachmentMap.forEach { key, value in
guard value.isEmpty else { return }
self.attachmentMap.removeValue(forKey: key)
}
}
mutating func insert(quoteMap: [Int64: MessageViewModel.MaybeUnresolvedQuotedInfo]) {
self.quoteMap.merge(quoteMap) { _, new in new }
}
mutating func insert(linkPreviews: [LinkPreview]) {
linkPreviews.forEach { preview in
self.linkPreviews[preview.url, default: []].insert(preview)
}
}
mutating func insert(reactions: [Int64: [Reaction]]) {
let sortedReactions: [Int64: [Reaction]] = reactions.mapValues {
$0.sorted { lhs, rhs in lhs.sortId < rhs.sortId }
}
self.reactions.merge(sortedReactions) { _, new in new }
/// Remove any empty lists
reactions.forEach { key, value in
guard value.isEmpty else { return }
self.reactions.removeValue(forKey: key)
}
}
mutating func insert(unblindedIdMap: [String: String]) {
self.unblindedIdMap.merge(unblindedIdMap) { _, new in new }
}
mutating func setTyping(_ isTyping: Bool, in threadId: String) {
if isTyping {
self.incomingTyping.insert(threadId)
} else {
self.incomingTyping.remove(threadId)
}
}
mutating func remove(threadIds: Set<String>) {
threadIds.forEach { threadId in
self.threads.removeValue(forKey: threadId)
self.contacts.removeValue(forKey: threadId)
self.groups.removeValue(forKey: threadId)
self.groupInfo.removeValue(forKey: threadId)
self.groupMembers.removeValue(forKey: threadId)
self.communities.removeValue(forKey: threadId)
self.communityModAdminIds.removeValue(forKey: threadId)
self.userModeratorOrAdmin.removeValue(forKey: threadId)
self.disappearingMessagesConfigurations.removeValue(forKey: threadId)
self.interactionStats.removeValue(forKey: threadId)
self.incomingTyping.remove(threadId)
let interactions: [Interaction] = Array(self.interactions.values)
interactions.forEach { interaction in
guard
let interactionId: Int64 = interaction.id,
interaction.threadId == threadId
else { return }
self.interactions.removeValue(forKey: interactionId)
self.attachmentMap[interactionId]?.forEach { attachments.removeValue(forKey: $0.attachmentId) }
self.attachmentMap.removeValue(forKey: interactionId)
}
}
}
mutating func remove(interactionIds: Set<Int64>) {
interactionIds.forEach { id in
self.interactions.removeValue(forKey: id)
self.reactions.removeValue(forKey: id)
self.attachmentMap[id]?.forEach {
self.attachments.removeValue(forKey: $0.attachmentId)
}
self.attachmentMap.removeValue(forKey: id)
}
}
mutating func remove(interactionStatsForThreadIds: Set<String>) {
interactionStatsForThreadIds.forEach { id in
guard let stats: ConversationInfoViewModel.InteractionStats = self.interactionStats[id] else {
return
}
self.interactionStats.removeValue(forKey: id)
self.interactions.removeValue(forKey: stats.latestInteractionId)
self.attachmentMap[stats.latestInteractionId]?.forEach { attachments.removeValue(forKey: $0.attachmentId) }
self.attachmentMap.removeValue(forKey: stats.latestInteractionId)
}
}
mutating func removeAttachmentMap(for interactionId: Int64) {
self.attachmentMap.removeValue(forKey: interactionId)
}
}
// MARK: - Convenience
public extension ConversationDataCache {
func contactDisplayName(for threadId: String) -> String {
/// We expect a non-nullable string so if it's invalid just return an empty string
guard
let thread: SessionThread = thread(for: threadId),
thread.variant == .contact
else { return "" }
let profile: Profile = (profile(for: thread.id) ?? Profile.defaultFor(thread.id))
return profile.displayName()
}
}