session-ios/Session/Home/HomeViewModel.swift
Morgan Pretty 06eef99766 Cleared out some legacy code, fixed a few bugs, got typing indicators and mentions working
Got mentions working again
Got typing indicators working again
Got the notification sound and preview preferences working
Fixed a few issues with attachment image loading
Fixed an issue where enum settings weren't getting stored correctly
2022-05-10 17:42:15 +10:00

461 lines
21 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import DifferenceKit
import SignalUtilitiesKit
public class HomeViewModel {
public enum Section: Differentiable {
case messageRequests
case threads
}
public struct ObservedInfo: Equatable {
let unreadMessageRequestCount: Int
let threadInfo: [ThreadInfo]
}
public struct ThreadInfo: FetchableRecord, Decodable, Equatable, Differentiable {
public struct GroupMemberInfo: FetchableRecord, Decodable, Equatable {
public let profile: Profile
}
public struct InteractionInfo: FetchableRecord, Decodable, Equatable {
public struct AuthorInfo: FetchableRecord, Decodable, Equatable {
public let id: String
public let displayName: String
public let nickname: String?
}
fileprivate static let timestampMsKey = CodingKeys.timestampMs.stringValue
fileprivate static let threadVariantKey = CodingKeys.threadVariant.stringValue
fileprivate static let authorInfoKey = CodingKeys.authorInfo.stringValue
fileprivate static let isOpenGroupInvitationKey = CodingKeys.isOpenGroupInvitation.stringValue
fileprivate static let recipientStatesKey = CodingKeys.recipientStates.stringValue
public let id: Int64?
public let variant: Interaction.Variant
public let timestampMs: Double
private let threadVariant: SessionThread.Variant
private let body: String?
private let attachments: [Attachment]?
private let authorId: String
private let authorInfo: AuthorInfo?
private let isOpenGroupInvitation: Bool
private let recipientStates: [RecipientState.State]?
public var authorName: String {
return Profile.displayName(
for: threadVariant,
id: (authorInfo?.id ?? authorId),
name: authorInfo?.displayName,
nickname: authorInfo?.nickname,
customFallback: (threadVariant == .contact && variant == .standardIncoming ?
"Anonymous" :
nil
)
)
}
public var text: String {
return Interaction.previewText(
variant: variant,
body: body,
authorDisplayName: authorName,
attachments: (attachments ?? []),
isOpenGroupInvitation: (isOpenGroupInvitation == true)
)
}
public var state: RecipientState.State {
return Interaction.state(for: (recipientStates ?? []))
}
}
fileprivate static let contactIsTypingKey = CodingKeys.contactIsTyping.stringValue
fileprivate static let closedGroupNameKey = CodingKeys.closedGroupName.stringValue
fileprivate static let openGroupNameKey = CodingKeys.openGroupName.stringValue
fileprivate static let openGroupProfilePictureDataKey = CodingKeys.openGroupProfilePictureData.stringValue
fileprivate static let currentUserProfileKey = CodingKeys.currentUserProfile.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
fileprivate static let currentUserIsClosedGroupAdminKey = CodingKeys.currentUserIsClosedGroupAdmin.stringValue
fileprivate static let threadUnreadCountKey = CodingKeys.threadUnreadCount.stringValue
fileprivate static let threadUnreadMentionCountKey = CodingKeys.threadUnreadMentionCount.stringValue
fileprivate static let lastInteractionInfoKey = CodingKeys.lastInteractionInfo.stringValue
public var differenceIdentifier: String { id }
public let id: String
public let variant: SessionThread.Variant
private let creationDateTimestamp: TimeInterval
public let contactIsTyping: Bool
public let closedGroupName: String?
public let openGroupName: String?
public let openGroupProfilePictureData: Data?
private let currentUserProfile: Profile
private let contactProfile: Profile?
private let closedGroupAvatarProfiles: [GroupMemberInfo]?
public let mutedUntilTimestamp: TimeInterval?
public let onlyNotifyForMentions: Bool
public let isPinned: Bool
/// A flag indicating whether the contact is blocked (will be null for non-contact threads)
private let contactIsBlocked: Bool?
public let isNoteToSelf: Bool
private let currentUserIsClosedGroupAdmin: Bool?
private let threadUnreadCount: UInt?
private let threadUnreadMentionCount: UInt?
public let lastInteractionInfo: InteractionInfo?
public var displayName: String {
return SessionThread.displayName(
threadId: id,
variant: variant,
closedGroupName: closedGroupName,
openGroupName: openGroupName,
isNoteToSelf: isNoteToSelf,
profile: contactProfile
)
}
public var 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
}
}
public var lastInteractionDate: Date {
guard let lastInteractionInfo: InteractionInfo = lastInteractionInfo else {
return Date(timeIntervalSince1970: creationDateTimestamp)
}
return Date(timeIntervalSince1970: (lastInteractionInfo.timestampMs / 1000))
}
/// A flag indicating whether the thread is blocked (only contact threads can be blocked)
public var isBlocked: Bool {
return (contactIsBlocked == true)
}
public var isGroupAdmin: Bool {
return (currentUserIsClosedGroupAdmin == true)
}
public var unreadCount: UInt {
return (threadUnreadCount ?? 0)
}
public var unreadMentionCount: UInt {
return (threadUnreadMentionCount ?? 0)
}
fileprivate init() {
self.id = "FALLBACK"
self.variant = .contact
self.creationDateTimestamp = 0
self.contactIsTyping = false
self.closedGroupName = nil
self.openGroupName = nil
self.openGroupProfilePictureData = nil
self.currentUserProfile = Profile(id: "", name: "")
self.contactProfile = nil
self.closedGroupAvatarProfiles = nil
self.mutedUntilTimestamp = nil
self.onlyNotifyForMentions = false
self.isPinned = false
self.contactIsBlocked = nil
self.isNoteToSelf = false
self.currentUserIsClosedGroupAdmin = nil
self.threadUnreadCount = nil
self.threadUnreadMentionCount = nil
self.lastInteractionInfo = nil
}
// MARK: - Query
public static func query(userPublicKey: String) -> QueryInterfaceRequest<ThreadInfo> {
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let contact: TypedTableAlias<Contact> = TypedTableAlias()
let typingIndicator: TypedTableAlias<ThreadTypingIndicator> = TypedTableAlias()
let closedGroup: TypedTableAlias<ClosedGroup> = TypedTableAlias()
let closedGroupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
let unreadInteractions: TableAlias = TableAlias()
let unreadMentions: TableAlias = TableAlias()
let lastInteraction: TableAlias = TableAlias()
let lastInteractionThread: TypedTableAlias<SessionThread> = TypedTableAlias()
let linkPreview: TypedTableAlias<LinkPreview> = TypedTableAlias()
let currentUserProfileExpression: CommonTableExpression = CommonTableExpression(
named: ThreadInfo.currentUserProfileKey,
request: Profile.filter(id: userPublicKey)
)
let unreadInteractionExpression: CommonTableExpression = CommonTableExpression(
named: ThreadInfo.threadUnreadCountKey,
request: Interaction
.select(
count(Interaction.Columns.id).forKey(ThreadInfo.threadUnreadCountKey),
Interaction.Columns.threadId
)
.filter(Interaction.Columns.wasRead == false)
.group(Interaction.Columns.threadId)
)
let unreadMentionsExpression: CommonTableExpression = CommonTableExpression(
named: ThreadInfo.threadUnreadMentionCountKey,
request: Interaction
.select(
count(Interaction.Columns.id).forKey(ThreadInfo.threadUnreadMentionCountKey),
Interaction.Columns.threadId
)
.filter(Interaction.Columns.wasRead == false)
.filter(Interaction.Columns.hasMention == true)
.group(Interaction.Columns.threadId)
)
let lastInteractionExpression: CommonTableExpression = CommonTableExpression(
named: ThreadInfo.lastInteractionInfoKey,
request: Interaction
.select(
Interaction.Columns.id,
Interaction.Columns.threadId,
Interaction.Columns.variant,
// 'max()' to get the latest
max(Interaction.Columns.timestampMs).forKey(ThreadInfo.InteractionInfo.timestampMsKey),
lastInteractionThread[.variant].forKey(ThreadInfo.InteractionInfo.threadVariantKey),
Interaction.Columns.body,
Interaction.Columns.authorId,
(linkPreview[.url] != nil).forKey(ThreadInfo.InteractionInfo.isOpenGroupInvitationKey)
)
.joining(required: Interaction.thread.aliased(lastInteractionThread))
.joining(
optional: Interaction.linkPreview
.filter(literal: Interaction.linkPreviewFilterLiteral)
.filter(LinkPreview.Columns.variant == LinkPreview.Variant.openGroupInvitation)
)
.including(all: Interaction.attachments)
.including(
all: Interaction.recipientStates
.select(RecipientState.Columns.state)
.forKey(ThreadInfo.InteractionInfo.recipientStatesKey)
)
.group(Interaction.Columns.threadId) // One interaction per thread
)
return SessionThread
.select(
thread[.id],
thread[.variant],
thread[.creationDateTimestamp],
(typingIndicator[.threadId] != nil).forKey(ThreadInfo.contactIsTypingKey),
closedGroup[.name].forKey(ThreadInfo.closedGroupNameKey),
openGroup[.name].forKey(ThreadInfo.openGroupNameKey),
openGroup[.imageData].forKey(ThreadInfo.openGroupProfilePictureDataKey),
thread[.mutedUntilTimestamp],
thread[.onlyNotifyForMentions],
thread[.isPinned],
contact[.isBlocked].forKey(ThreadInfo.contactIsBlockedKey),
SessionThread.isNoteToSelf(userPublicKey: userPublicKey).forKey(ThreadInfo.isNoteToSelfKey),
(closedGroupMember[.profileId] != nil).forKey(ThreadInfo.currentUserIsClosedGroupAdminKey),
unreadInteractions[ThreadInfo.threadUnreadCountKey],
unreadMentions[ThreadInfo.threadUnreadMentionCountKey]
)
.aliased(thread)
.joining(
optional: SessionThread.contact
.aliased(contact)
.including(
optional: Contact.profile
.forKey(ThreadInfo.contactProfileKey)
)
)
.joining(optional: SessionThread.typingIndicator.aliased(typingIndicator))
.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(ThreadInfo.closedGroupAvatarProfilesKey)
)
.joining(
optional: ClosedGroup.members
.aliased(closedGroupMember)
.filter(GroupMember.Columns.role == GroupMember.Role.admin)
.filter(GroupMember.Columns.profileId == userPublicKey)
)
)
.joining(optional: SessionThread.openGroup.aliased(openGroup))
.with(currentUserProfileExpression)
.including(
required: SessionThread.association(to: currentUserProfileExpression)
.forKey(ThreadInfo.currentUserProfileKey)
)
.with(unreadInteractionExpression)
.joining(
optional: SessionThread
.association(
to: unreadInteractionExpression,
on: { thread, unreadGroup in
thread[SessionThread.Columns.id] == unreadGroup[Interaction.Columns.threadId]
}
)
.aliased(unreadInteractions)
)
.with(unreadMentionsExpression)
.joining(
optional: SessionThread
.association(
to: unreadMentionsExpression,
on: { thread, unreadMentions in
thread[SessionThread.Columns.id] == unreadMentions[Interaction.Columns.threadId]
}
)
.aliased(unreadMentions)
)
.with(lastInteractionExpression)
.including(
optional: SessionThread
.association(
to: lastInteractionExpression,
on: { thread, lastInteraction in
thread[SessionThread.Columns.id] == lastInteraction[Interaction.Columns.threadId]
}
)
.aliased(lastInteraction)
.forKey(ThreadInfo.lastInteractionInfoKey)
.including(
optional: lastInteractionExpression
.association(
to: CommonTableExpression(
named: Profile.databaseTableName,
request: Profile.select(.id, .name, .nickname)
),
on: { lastInteraction, profile in
lastInteraction[Interaction.Columns.authorId] == profile[Profile.Columns.id]
}
)
.forKey(ThreadInfo.InteractionInfo.authorInfoKey)
)
)
.order(
lastInteraction[Interaction.Columns.timestampMs].desc,
thread[.creationDateTimestamp].desc
)
.asRequest(of: ThreadInfo.self)
}
}
public struct Item: Equatable, Differentiable {
public var differenceIdentifier: String {
return threadInfo.id
}
let unreadCount: Int
let threadInfo: ThreadInfo
}
/// This value is the current state of the view
public private(set) var viewData: [ArraySection<Section, Item>] = []
/// 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 -> ObservedInfo in
let userPublicKey: String = getUserHexEncodedPublicKey(db)
let unreadMessageRequestCount: Int = try SessionThread
.filter(SessionThread.isMessageRequest(userPublicKey: userPublicKey))
.joining(optional: SessionThread.contact)
.joining(
required: SessionThread.interactions
.filter(Interaction.Columns.wasRead == false)
)
.group(SessionThread.Columns.id)
.fetchCount(db)
return ObservedInfo(
unreadMessageRequestCount: (db[.hasHiddenMessageRequests] ? 0 : unreadMessageRequestCount),
threadInfo: try ThreadInfo
.query(userPublicKey: userPublicKey)
.filter(SessionThread.Columns.shouldBeVisible == true)
.filter(SessionThread.isNotMessageRequest(userPublicKey: userPublicKey))
.filter(
// Only show the Note to Self if it has a lastInteraction
SessionThread.Columns.id != userPublicKey ||
SQL(stringLiteral: "\(ThreadInfo.lastInteractionInfoKey).id IS NOT NULL")
)
.fetchAll(db)
)
}
.removeDuplicates()
.map { observedInfo -> [ArraySection<Section, Item>] in
return [
ArraySection(
model: .messageRequests,
elements: [
// If there are no unread message requests then hide the message request banner
(observedInfo.unreadMessageRequestCount == 0 ?
nil :
Item(
unreadCount: observedInfo.unreadMessageRequestCount,
threadInfo: ThreadInfo() // Won't be used
)
)
].compactMap { $0 }
),
ArraySection(
model: .threads,
elements: observedInfo.threadInfo
.map { info in
Item(
unreadCount: Int(info.unreadCount),
threadInfo: info
)
}
),
]
}
// MARK: - Functions
public func updateData(_ updatedData: [ArraySection<Section, Item>]) {
self.viewData = updatedData
}
}