// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation import GRDB import DifferenceKit import SignalUtilitiesKit import SessionMessagingKit public class ThreadPickerViewModel { // MARK: - Initialization init() { viewData = ViewData( currentUserProfile: Profile.fetchOrCreateCurrentUser(), items: [] ) } public struct Item: FetchableRecord, Decodable, Equatable, Differentiable { public struct GroupMemberInfo: FetchableRecord, Decodable, Equatable { public let profile: Profile } fileprivate static let closedGroupNameKey = CodingKeys.closedGroupName.stringValue fileprivate static let openGroupNameKey = CodingKeys.openGroupName.stringValue fileprivate static let openGroupProfilePictureDataKey = CodingKeys.openGroupProfilePictureData.stringValue fileprivate static let contactProfileKey = CodingKeys.contactProfile.stringValue fileprivate static let closedGroupAvatarProfilesKey = CodingKeys.closedGroupAvatarProfiles.stringValue fileprivate static let contactIsBlockedKey = CodingKeys.contactIsBlocked.stringValue fileprivate static let isNoteToSelfKey = CodingKeys.isNoteToSelf.stringValue public var differenceIdentifier: String { id } public let id: String public let variant: SessionThread.Variant public let closedGroupName: String? public let openGroupName: String? public let openGroupProfilePictureData: Data? private let contactProfile: Profile? private let closedGroupAvatarProfiles: [GroupMemberInfo]? /// A flag indicating whether the contact is blocked (will be null for non-contact threads) private let contactIsBlocked: Bool? public let isNoteToSelf: Bool public func displayName(currentUserProfile: Profile) -> String { return SessionThread.displayName( threadId: id, variant: variant, closedGroupName: closedGroupName, openGroupName: openGroupName, isNoteToSelf: isNoteToSelf, profile: contactProfile ) } public func profile(currentUserProfile: Profile) -> Profile? { switch variant { case .contact: return contactProfile case .openGroup: return nil case .closedGroup: // If there is only a single user in the group then we want to use the current user // profile at the back if closedGroupAvatarProfiles?.count == 1 { return currentUserProfile } return closedGroupAvatarProfiles?.first?.profile } } public var additionalProfile: Profile? { switch variant { case .closedGroup: return closedGroupAvatarProfiles?.last?.profile default: return nil } } /// A flag indicating whether the thread is blocked (only contact threads can be blocked) public var isBlocked: Bool { return (contactIsBlocked == true) } // MARK: - Query public static func query(userPublicKey: String) -> QueryInterfaceRequest { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() let closedGroup: TypedTableAlias = TypedTableAlias() let openGroup: TypedTableAlias = TypedTableAlias() let lastInteraction: TableAlias = TableAlias() let lastInteractionTimestampExpression: CommonTableExpression = Interaction.lastInteractionTimestamp( timestampMsKey: Interaction.Columns.timestampMs.stringValue ) // FIXME: Exclude unwritable opengroups return SessionThread .select( thread[.id], thread[.variant], thread[.creationDateTimestamp], closedGroup[.name].forKey(Item.closedGroupNameKey), openGroup[.name].forKey(Item.openGroupNameKey), openGroup[.imageData].forKey(Item.openGroupProfilePictureDataKey), contact[.isBlocked].forKey(Item.contactIsBlockedKey), SessionThread.isNoteToSelf(userPublicKey: userPublicKey).forKey(Item.isNoteToSelfKey) ) .filter(SessionThread.Columns.shouldBeVisible == true) .filter(SessionThread.isNotMessageRequest(userPublicKey: userPublicKey)) .filter( // Only show the Note to Self if it has an interaction SessionThread.Columns.id != userPublicKey || lastInteraction[Interaction.Columns.timestampMs] != nil ) .aliased(thread) .joining( optional: SessionThread.contact .aliased(contact) .including( optional: Contact.profile .forKey(Item.contactProfileKey) ) ) .joining( optional: SessionThread.closedGroup .aliased(closedGroup) .including( all: ClosedGroup.members .filter(GroupMember.Columns.role == GroupMember.Role.standard) .filter(GroupMember.Columns.profileId != userPublicKey) .order(GroupMember.Columns.profileId) // Sort to provide a level of stability .limit(2) .including(required: GroupMember.profile) .forKey(Item.closedGroupAvatarProfilesKey) ) ) .joining(optional: SessionThread.openGroup.aliased(openGroup)) .with(lastInteractionTimestampExpression) .including( optional: SessionThread .association( to: lastInteractionTimestampExpression, on: { thread, lastInteraction in thread[SessionThread.Columns.id] == lastInteraction[Interaction.Columns.threadId] } ) .aliased(lastInteraction) ) .order( ( lastInteraction[Interaction.Columns.timestampMs] ?? (thread[.creationDateTimestamp] * 1000) ).desc ) .asRequest(of: Item.self) } } public struct ViewData: Equatable { let currentUserProfile: Profile let items: [Item] } /// This value is the current state of the view public private(set) var viewData: ViewData /// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise /// performance https://github.com/groue/GRDB.swift#valueobservation-performance /// /// **Note:** The 'trackingConstantRegion' is optimised in such a way that the request needs to be static /// otherwise there may be situations where it doesn't get updates, this means we can't have conditional queries public lazy var observableViewData = ValueObservation .trackingConstantRegion { db -> ViewData in let currentUserProfile: Profile = Profile.fetchOrCreateCurrentUser(db) return ViewData( currentUserProfile: Profile.fetchOrCreateCurrentUser(db), items: try Item .query(userPublicKey: currentUserProfile.id) .fetchAll(db) ) } .removeDuplicates() // MARK: - Functions public func updateData(_ updatedData: ViewData) { self.viewData = updatedData } }