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

227 lines
11 KiB
Swift

// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import Combine
import GRDB
import SessionUtilitiesKit
import SessionNetworkingKit
// MARK: - Log.Category
private extension Log.Category {
static let cat: Log.Category = .create("GroupLeavingJob", defaultLevel: .info)
}
// MARK: - GroupLeavingJob
public enum GroupLeavingJob: JobExecutor {
public static var maxFailureCount: Int = 0
public static var requiresThreadId: Bool = true
public static var requiresInteractionId: Bool = true
public static func run<S: Scheduler>(
_ job: Job,
scheduler: S,
success: @escaping (Job, Bool) -> Void,
failure: @escaping (Job, Error, Bool) -> Void,
deferred: @escaping (Job) -> Void,
using dependencies: Dependencies
) {
guard
let detailsData: Data = job.details,
let details: Details = try? JSONDecoder(using: dependencies).decode(Details.self, from: detailsData),
let threadId: String = job.threadId,
let interactionId: Int64 = job.interactionId
else { return failure(job, JobRunnerError.missingRequiredDetails, true) }
let destination: Message.Destination = .group(publicKey: threadId)
dependencies[singleton: .storage]
.writePublisher(updates: { db -> RequestType in
guard (try? ClosedGroup.exists(db, id: threadId)) == true else {
Log.error(.cat, "Failed due to non-existent group")
throw MessageError.invalidGroupUpdate("Could not retrieve group")
}
let userSessionId: SessionId = dependencies[cache: .general].sessionId
let isAdminUser: Bool = GroupMember
.filter(GroupMember.Columns.groupId == threadId)
.filter(GroupMember.Columns.profileId == userSessionId.hexString)
.filter(GroupMember.Columns.role == GroupMember.Role.admin)
.isNotEmpty(db)
let numAdminUsers: Int = (try? GroupMember
.filter(GroupMember.Columns.groupId == threadId)
.filter(GroupMember.Columns.role == GroupMember.Role.admin)
.distinct()
.fetchCount(db))
.defaulting(to: 0)
let finalBehaviour: Details.Behaviour = {
guard
dependencies.mutate(cache: .libSession, { cache in
!cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: threadId)) ||
!cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: threadId))
})
else { return .delete }
return details.behaviour
}()
switch (finalBehaviour, isAdminUser, (isAdminUser && numAdminUsers == 1)) {
case (.leave, _, false):
let disappearingConfig: DisappearingMessagesConfiguration? = try? DisappearingMessagesConfiguration.fetchOne(db, id: threadId)
let authMethod: AuthenticationMethod = try Authentication.with(swarmPublicKey: threadId, using: dependencies)
return .sendLeaveMessage(authMethod, disappearingConfig)
case (.delete, true, _), (.leave, true, true):
let groupSessionId: SessionId = SessionId(.group, hex: threadId)
/// Skip the automatic config sync because we want to perform it synchronously as part of this job
try dependencies.mutate(cache: .libSession) { cache in
try cache.withCustomBehaviour(.skipAutomaticConfigSync, for: groupSessionId) {
try cache.deleteGroupForEveryone(db, groupSessionId: groupSessionId)
}
}
return .configSync
case (.delete, false, _): return .configSync
default: throw MessageError.invalidGroupUpdate("Unsupported group leaving configuration")
}
})
.tryFlatMap { requestType -> AnyPublisher<Void, Error> in
switch requestType {
case .sendLeaveMessage(let authMethod, let disappearingConfig):
return try Network.SnodeAPI
.preparedBatch(
requests: [
/// Don't expire the `GroupUpdateMemberLeftMessage` as that's not a UI-based
/// message (it's an instruction for admin devices)
try MessageSender.preparedSend(
message: GroupUpdateMemberLeftMessage(),
to: destination,
namespace: destination.defaultNamespace,
interactionId: job.interactionId,
attachments: nil,
authMethod: authMethod,
onEvent: MessageSender.standardEventHandling(using: dependencies),
using: dependencies
),
try MessageSender.preparedSend(
message: GroupUpdateMemberLeftNotificationMessage()
.with(disappearingConfig),
to: destination,
namespace: destination.defaultNamespace,
interactionId: nil,
attachments: nil,
authMethod: authMethod,
onEvent: MessageSender.standardEventHandling(using: dependencies),
using: dependencies
)
],
requireAllBatchResponses: false,
swarmPublicKey: threadId,
using: dependencies
)
.send(using: dependencies)
.map { _ in () }
.eraseToAnyPublisher()
case .configSync:
return ConfigurationSyncJob
.run(swarmPublicKey: threadId, using: dependencies)
.map { _ in () }
.eraseToAnyPublisher()
}
}
.tryCatch { error -> AnyPublisher<Void, Error> in
/// If it failed due to one of these errors then clear out any associated data (as the `SessionThread` exists but
/// either the data required to send the `MEMBER_LEFT` message doesn't or the user has had their access to the
/// group revoked which would leave the user in a state where they can't leave the group)
switch (error as? MessageError, error as? SnodeAPIError, error as? CryptoError) {
case (.invalidGroupUpdate, _, _), (.encodingFailed, _, _),
(_, .unauthorised, _), (_, _, .invalidAuthentication):
return Just(()).setFailureType(to: Error.self).eraseToAnyPublisher()
default: throw error
}
}
.subscribe(on: scheduler, using: dependencies)
.receive(on: scheduler, using: dependencies)
.sinkUntilComplete(
receiveCompletion: { result in
switch result {
case .failure(let error):
// Update the interaction to indicate we failed to leave the group (it shouldn't
// be possible to fail to delete a group so we don't have copy for that case(
dependencies[singleton: .storage].writeAsync { db in
let updatedBody: String = "groupLeaveErrorFailed"
.put(key: "group_name", value: ((try? ClosedGroup.fetchOne(db, id: threadId))?.name ?? ""))
.localized()
try Interaction
.filter(id: interactionId)
.updateAll(
db,
Interaction.Columns.variant
.set(to: Interaction.Variant.infoGroupCurrentUserErrorLeaving),
Interaction.Columns.body.set(to: updatedBody)
)
}
failure(job, error, true)
case .finished:
// Remove all of the group data
dependencies[singleton: .storage].writeAsync { db in
try ClosedGroup.removeData(
db,
threadIds: [threadId],
dataToRemove: .allData,
using: dependencies
)
}
success(job, false)
}
}
)
}
}
// MARK: - GroupLeavingJob.Details
extension GroupLeavingJob {
public struct Details: Codable {
private enum CodingKeys: String, CodingKey {
case behaviour
}
public enum Behaviour: Int, Codable {
/// Will leave the group, deleting it from the current device but letting the group continue to exist as long
/// the current user isn't the only admin
case leave = 1
/// Will permanently delete the group, this will result in the group being deleted from all member devices
case delete = 2
}
public let behaviour: Behaviour
// MARK: - Initialization
public init(behaviour: Behaviour) {
self.behaviour = behaviour
}
}
}
// MARK: - Convenience
private extension GroupLeavingJob {
enum RequestType {
case sendLeaveMessage(AuthenticationMethod, DisappearingMessagesConfiguration?)
case configSync
}
}