Files
Morgan Pretty ac9816b118 Massive refactors of the SessionThreadViewModel and MessageViewModel
The `SessionThreadViewModel` has been renamed to `ConversationInfoViewModel` and is now a simple struct (as opposed to a FetchableRecord with a handful of behemoth database queries)

The `ConversationInfoViewModel` and `MessageViewModel` now get initialised with a `ConversationDataCache` which is an object that holds a bunch of [ID -> Data] stores for various types that are needed to construct the aforementioned view models. The `ConversationDataCache` will generally be populated via the `ConversationDataHelper` which, when combined with our `ObservableEvent` system, is where the majority of the work for this refactoring has gone. This helper essentially has 4 parts:
- `determineFetchRequirements`: Reviews any events which have come through and determines whether anything needs to be fetched from the database
- `applyNonDatabaseEvents`: Updates the cached data directly based on delta information included in the `ObservableEvent` instances
- `fetchFromDatabase`: Fetches any data from the database based on the info returned from `determineFetchRequirements`, this function will also resolve any dependant data that newly fetched objects need if they aren't already in the cache
- `fetchFromLibSession`: Retrieves requested data from libSession

Using the above setup all screens which contain conversation or message data now use these two view models, and can share the state of the view models between each other to reduce the need to refetch data when entering a new screen

Some additional changes included due to the above are:
• No longer need to pass `displayNameRetriever` throughout
• Removed deprecated display name retrieval functions
• Removed some duplicate code
• Removed some synchronous database query hacks we needed until refactoring had been completed
• Refactored mentions to be handled by tags
• Refactored and optimised the searching logic (also added some caching)
• Refactored the ThreadSettingsViewModel to use the new ObservationBuilder system
• Refactored the ThreadPickerViewModel to use the new ObservationBuilder system
• Refactored the HomeViewModel to retrieve data using the `ConversationDataHelper`
• Refactored the ConversationViewModel to retrieve data using the `ConversationDataHelper`
• Fixed an issue where communities weren't getting the correct "joined" timestamp
2025-12-11 16:47:37 +11:00

243 lines
11 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUIKit
import SessionUtilitiesKit
public struct GroupMember: Sendable, Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
public static var databaseTableName: String { "groupMember" }
public typealias Columns = CodingKeys
public enum CodingKeys: String, CodingKey, ColumnExpression {
case groupId
case profileId
case role
case roleStatus
case isHidden
}
public enum Role: Int, Sendable, Codable, Comparable, CaseIterable, DatabaseValueConvertible {
case standard
case zombie
case moderator
case admin
public static func < (lhs: Role, rhs: Role) -> Bool { lhs.rawValue < rhs.rawValue }
}
public enum RoleStatus: Int, Sendable, Codable, CaseIterable, DatabaseValueConvertible {
case accepted
case pending
case failed
case notSentYet
case sending
case pendingRemoval
case unknown = 100
}
public let groupId: String
public let profileId: String
public let role: Role
public let roleStatus: RoleStatus
public let isHidden: Bool
// MARK: - Initialization
public init(
groupId: String,
profileId: String,
role: Role,
roleStatus: RoleStatus,
isHidden: Bool
) {
self.groupId = groupId
self.profileId = profileId
self.role = role
self.roleStatus = roleStatus
self.isHidden = isHidden
}
}
// MARK: - Decoding
extension GroupMember {
public init(from decoder: Decoder) throws {
let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
self = GroupMember(
groupId: try container.decode(String.self, forKey: .groupId),
profileId: try container.decode(String.self, forKey: .profileId),
role: try container.decode(Role.self, forKey: .role),
// Added in `_018_GroupsRebuildChanges`
roleStatus: ((try? container.decode(RoleStatus.self, forKey: .roleStatus)) ?? .accepted),
// Added in `_006_FixHiddenModAdminSupport`
isHidden: ((try? container.decode(Bool.self, forKey: .isHidden)) ?? false)
)
}
}
// MARK: - Convenience
public extension GroupMember {
var statusDescription: String? {
switch (role, roleStatus) {
case (_, .accepted): return nil // Nothing for "final" state
case (.zombie, _), (.moderator, _): return nil // Unused cases
case (.standard, .notSentYet): return "groupInviteNotSent".localized()
case (.standard, .sending): return "groupInviteSending".putNumber(1).localized()
case (.standard, .pending): return "groupInviteSent".localized()
case (.standard, .failed): return "groupInviteFailed".localized()
case (_, .pendingRemoval): return "groupPendingRemoval".localized()
case (.standard, .unknown): return "groupInviteStatusUnknown".localized()
case (.admin, .notSentYet): return "adminPromotionNotSent".localized()
case (.admin, .sending): return "adminSendingPromotion".putNumber(1).localized()
case (.admin, .pending): return "adminPromotionSent".localized()
case (.admin, .failed): return "adminPromotionFailed".localized()
case (.admin, .unknown): return "adminPromotionStatusUnknown".localized()
}
}
var statusDescriptionColor: ThemeValue {
switch (role, roleStatus) {
case (.zombie, _), (.moderator, _): return .textPrimary
case (_, .failed): return .danger
default: return .textPrimary
}
}
}
extension GroupMember: ProfileAssociated {
public var profileIcon: ProfilePictureView.Info.ProfileIcon {
switch role {
case .moderator, .admin: return .crown
default: return .none
}
}
public func itemDescription(using dependencies: Dependencies) -> String? { return statusDescription }
public func itemDescriptionColor(using dependencies: Dependencies) -> ThemeValue { return statusDescriptionColor }
public static func compareForManagement(
lhs: WithProfile<GroupMember>,
rhs: WithProfile<GroupMember>
) -> Bool {
let isUpdatedGroup: Bool = (((try? SessionId.Prefix(from: lhs.value.groupId)) ?? .group) == .group)
let lhsDisplayName: String = (lhs.profile?.displayName() ?? lhs.profileId.truncated())
let rhsDisplayName: String = (rhs.profile?.displayName() ?? rhs.profileId.truncated())
// Legacy groups have a different sorting behaviour
guard isUpdatedGroup else {
switch (lhs.value.role, rhs.value.role) {
case (.zombie, .standard), (.zombie, .moderator), (.zombie, .admin): return true
case (.standard, .zombie), (.moderator, .zombie), (.admin, .zombie): return false
default:
guard lhs.value.role == rhs.value.role else { return lhs.value.role < rhs.value.role }
return (lhsDisplayName.lowercased() < rhsDisplayName.lowercased())
}
}
/// We want to sort the member list so the most important info is at the top of the list, this means that we want to prioritise
/// • Invite failed, sorted as NameSortingOrder
/// • Invite not sent, sorted as NameSortingOrder
/// • Sending invite, sorted as NameSortingOrder
/// • Invite sent, sorted as NameSortingOrder
/// • Invite status unknown, sorted as NameSortingOrder
/// • Pending removal, sorted as NameSortingOrder
/// • Admin promotion failed, sorted as NameSortingOrder
/// • Admin promotion not sent, sorted as NameSortingOrder
/// • Sending admin promotion, sorted as NameSortingOrder
/// • Admin promotion sent, sorted as NameSortingOrder
/// • Admin promotion status unknown, sorted as NameSortingOrder
/// • Admin, sorted as NameSortingOrder
/// • Member, sorted as NameSortingOrder
///
/// And the current user should appear at the top of their respective group
let userSessionId: SessionId = lhs.currentUserSessionId
let desiredStatusOrder: [RoleStatus] = [
.failed, .notSentYet, .sending, .pending, .unknown, .pendingRemoval
]
/// If the role and status match then we want to sort by current user, no-name members by id, then by name
guard lhs.value.role != rhs.value.role || lhs.value.roleStatus != rhs.value.roleStatus else {
switch (lhs.profileId, rhs.profileId, lhs.profile?.name, rhs.profile?.name) {
case (userSessionId.hexString, userSessionId.hexString, _, _):
/// This case shouldn't be possible and is more to make the unit tests a bit nicer to read
return (lhsDisplayName.lowercased() < rhsDisplayName.lowercased())
case (userSessionId.hexString, _, _, _): return true
case (_, userSessionId.hexString, _, _): return false
case (_, _, .none, .some): return true
case (_, _, .some, .none): return false
case (_, _, .none, .none): return (lhsDisplayName.lowercased() < rhsDisplayName.lowercased())
case (_, _, .some, .some): return (lhsDisplayName.lowercased() < rhsDisplayName.lowercased())
}
}
switch (lhs.value.role, lhs.value.roleStatus, rhs.value.role, rhs.value.roleStatus) {
/// Non-accepted standard before admin
case (.standard, .failed, .admin, _), (.standard, .notSentYet, .admin, _),
(.standard, .sending, .admin, _), (.standard, .pending, .admin, _),
(.standard, .unknown, .admin, _), (.standard, .pendingRemoval, .admin, _):
return true
/// Non-accepted admin before accepted standard
case (.admin, .failed, .standard, .accepted), (.admin, .notSentYet, .standard, .accepted),
(.admin, .sending, .standard, .accepted), (.admin, .pending, .standard, .accepted),
(.admin, .unknown, .standard, .accepted), (.admin, .pendingRemoval, .standard, .accepted):
return true
/// Accepted admin before accepted standard
case (.admin, .accepted, .standard, .accepted): return true
/// Otherwise we should order based on the status position in `desiredStatusOrder`
default:
let lhsIndex = desiredStatusOrder.firstIndex(of: lhs.value.roleStatus)
let rhsIndex = desiredStatusOrder.firstIndex(of: rhs.value.roleStatus)
return ((lhsIndex ?? desiredStatusOrder.endIndex) < rhsIndex ?? desiredStatusOrder.endIndex)
}
}
public static func compare(
lhs: WithProfile<GroupMember>,
rhs: WithProfile<GroupMember>
) -> Bool {
let isUpdatedGroup: Bool = (((try? SessionId.Prefix(from: lhs.value.groupId)) ?? .group) == .group)
let lhsDisplayName: String = (lhs.profile?.displayName() ?? lhs.profileId.truncated())
let rhsDisplayName: String = (rhs.profile?.displayName() ?? rhs.profileId.truncated())
// Legacy groups have a different sorting behaviour
guard isUpdatedGroup else {
switch (lhs.value.role, rhs.value.role) {
case (.zombie, .standard), (.zombie, .moderator), (.zombie, .admin): return true
case (.standard, .zombie), (.moderator, .zombie), (.admin, .zombie): return false
default:
guard lhs.value.role == rhs.value.role else { return lhs.value.role < rhs.value.role }
return (lhsDisplayName.lowercased() < rhsDisplayName.lowercased())
}
}
/// We want to sort the member list so the most important info is at the top of the list, this means that we want to prioritise
/// • Current user
/// • Admin, sorted as NameSortingOrder
/// • Member, sorted as NameSortingOrder
let userSessionId: SessionId = lhs.currentUserSessionId
switch (lhs.profileId, rhs.profileId, lhs.value.role, rhs.value.role) {
/// Current user first
case (userSessionId.hexString, _, _, _): return true
case (_, userSessionId.hexString, _, _): return false
/// Admin before standard
case (_, _, .admin, .standard): return true
case (_, _, .standard, .admin): return false
/// Otherwise we should sort by NameSortingOrder
default: return (lhsDisplayName.lowercased() < rhsDisplayName.lowercased())
}
}
}