Browse Source

Optimised the home screen query (~50% speed improvement)

Updated to the latest version of GRDB
Renamed some variables for clarity
Updated the "seed viewed" banner on the HomeVC to be driven by a database setting to be consistent with other UI changes
pull/612/head
Morgan Pretty 6 months ago
parent
commit
18d833f152
  1. 4
      Podfile.lock
  2. 4
      Session/Conversations/ConversationViewModel.swift
  3. 107
      Session/Home/HomeVC.swift
  4. 71
      Session/Home/HomeViewModel.swift
  5. 14
      Session/Notifications/AppNotifications.swift
  6. 4
      Session/Onboarding/Onboarding.swift
  7. 6
      Session/Onboarding/SeedReminderView.swift
  8. 4
      Session/Onboarding/SeedVC.swift
  9. 4
      Session/Settings/SeedModal.swift
  10. 1
      SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift
  11. 4
      SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift
  12. 2
      SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift
  13. 5
      SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift
  14. 11
      SessionMessagingKit/Database/Models/Interaction.swift
  15. 10
      SessionMessagingKit/Database/Models/Profile.swift
  16. 43
      SessionMessagingKit/Database/Models/RecipientState.swift
  17. 63
      SessionMessagingKit/Database/Models/SessionThread.swift
  18. 4
      SessionMessagingKit/Database/Notification+Contacts.swift
  19. 46
      SessionMessagingKit/Shared Models/MessageViewModel.swift
  20. 170
      SessionMessagingKit/Shared Models/SessionThreadViewModel.swift
  21. 3
      SessionMessagingKit/Utilities/Preferences.swift
  22. 10
      SessionNotificationServiceExtension/NSENotificationPresenter.swift
  23. 2
      SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift
  24. 2
      SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift
  25. 2
      SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift
  26. 2
      SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift
  27. 2
      SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift
  28. 2
      SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift
  29. 1
      SessionUtilitiesKit/General/SNUserDefaults.swift
  30. 6
      SignalUtilitiesKit/Utilities/Notification+Loki.swift

4
Podfile.lock

@ -27,7 +27,7 @@ PODS:
- DifferenceKit/Core (1.2.0)
- DifferenceKit/UIKitExtension (1.2.0):
- DifferenceKit/Core
- GRDB.swift/SQLCipher (5.24.0):
- GRDB.swift/SQLCipher (5.24.1):
- SQLCipher (>= 3.4.0)
- Mantle (2.1.0):
- Mantle/extobjc (= 2.1.0)
@ -203,7 +203,7 @@ SPEC CHECKSUMS:
CryptoSwift: a532e74ed010f8c95f611d00b8bbae42e9fe7c17
Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6
DifferenceKit: 5659c430bb7fe45876fa32ce5cba5d6167f0c805
GRDB.swift: 7ecc8799aaa97cf1fbbcfa9d75821aa920cb713f
GRDB.swift: b3180ce2135fc06a453297889b746b1478c4d8c7
Mantle: 2fa750afa478cd625a94230fbf1c13462f29395b
NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667
OpenSSL-Universal: e7311447fd2419f57420c79524b641537387eff2

4
Session/Conversations/ConversationViewModel.swift

@ -180,9 +180,9 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
public var onInteractionChange: (([SectionModel]) -> ())?
private func process(data: [MessageViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
let typingIndicator: MessageViewModel? = data.first(where: { $0.isTypingIndicator })
let typingIndicator: MessageViewModel? = data.first(where: { $0.isTypingIndicator == true })
let sortedData: [MessageViewModel] = data
.filter { !$0.isTypingIndicator }
.filter { $0.isTypingIndicator != true }
.sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs }
// We load messages from newest to oldest so having a pageOffset larger than zero means

107
Session/Home/HomeVC.swift

@ -34,6 +34,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
result.subtitle = NSLocalizedString("view_seed_reminder_subtitle_1", comment: "")
result.setProgress(0.8, animated: false)
result.delegate = self
result.isHidden = !self.viewModel.state.showViewedSeedBanner
return result
}()
@ -131,13 +132,10 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
setUpNavBarSessionHeading()
// Recovery phrase reminder
let hasViewedSeed = UserDefaults.standard[.hasViewedSeed]
if !hasViewedSeed {
view.addSubview(seedReminderView)
seedReminderView.pin(.leading, to: .leading, of: view)
seedReminderView.pin(.top, to: .top, of: view)
seedReminderView.pin(.trailing, to: .trailing, of: view)
}
view.addSubview(seedReminderView)
seedReminderView.pin(.leading, to: .leading, of: view)
seedReminderView.pin(.top, to: .top, of: view)
seedReminderView.pin(.trailing, to: .trailing, of: view)
// Loading conversations label
view.addSubview(loadingConversationsLabel)
@ -149,9 +147,10 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
// Table view
view.addSubview(tableView)
tableView.pin(.leading, to: .leading, of: view)
if !hasViewedSeed {
if self.viewModel.state.showViewedSeedBanner {
tableViewTopConstraint = tableView.pin(.top, to: .bottom, of: seedReminderView)
} else {
}
else {
tableViewTopConstraint = tableView.pin(.top, to: .top, of: view, withInset: Values.smallSpacing)
}
tableView.pin(.trailing, to: .trailing, of: view)
@ -187,11 +186,6 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
name: UIApplication.didEnterBackgroundNotification, object: nil
)
notificationCenter.addObserver(self, selector: #selector(handleProfileDidChangeNotification(_:)), name: .otherUsersProfileDidChange, object: nil)
notificationCenter.addObserver(self, selector: #selector(handleLocalProfileDidChangeNotification(_:)), name: .localProfileDidChange, object: nil)
notificationCenter.addObserver(self, selector: #selector(handleSeedViewedNotification(_:)), name: .seedViewed, object: nil)
notificationCenter.addObserver(self, selector: #selector(handleBlockedContactsUpdatedNotification(_:)), name: .blockedContactsUpdated, object: nil)
// Start polling if needed (i.e. if the user just created or restored their Session ID)
if Identity.userExists(), let appDelegate: AppDelegate = UIApplication.shared.delegate as? AppDelegate {
appDelegate.startPollersIfNeeded()
@ -235,21 +229,28 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
private func startObservingChanges() {
// Start observing for data changes
dataChangeObservable = GRDBStorage.shared.start(
viewModel.observableViewData,
viewModel.observableState,
// If we haven't done the initial load the trigger it immediately (blocking the main
// thread so we remain on the launch screen until it completes to be consistent with
// the old behaviour)
scheduling: (hasLoadedInitialData ?
.async(onQueue: .main) :
.immediate
),
onError: { _ in },
onChange: { [weak self] viewData in
onChange: { [weak self] state in
// The default scheduler emits changes on the main thread
self?.handleUpdates(viewData)
self?.handleUpdates(state)
}
)
}
private func handleUpdates(_ updatedViewData: [ArraySection<Section, Item>]) {
private func handleUpdates(_ updatedState: HomeViewModel.State) {
// Ensure the first load runs without animations (if we don't do this the cells will animate
// in from a frame of CGRect.zero)
guard hasLoadedInitialData else {
hasLoadedInitialData = true
UIView.performWithoutAnimation { handleUpdates(updatedViewData) }
UIView.performWithoutAnimation { handleUpdates(updatedState) }
return
}
@ -258,48 +259,42 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
// Show the empty state if there is no data
emptyStateView.isHidden = (
!updatedViewData.isEmpty &&
updatedViewData.contains(where: { !$0.elements.isEmpty })
!updatedState.sections.isEmpty &&
updatedState.sections.contains(where: { !$0.elements.isEmpty })
)
// Update the 'view seed' UI
if updatedState.showViewedSeedBanner != self.viewModel.state.showViewedSeedBanner {
tableViewTopConstraint.isActive = false
seedReminderView.isHidden = !updatedState.showViewedSeedBanner
if updatedState.showViewedSeedBanner {
tableViewTopConstraint = tableView.pin(.top, to: .bottom, of: seedReminderView)
}
else {
tableViewTopConstraint = tableView.pin(.top, to: .top, of: view, withInset: Values.smallSpacing)
}
}
// Reload the table content (animate changes after the first load)
tableView.reload(
using: StagedChangeset(source: viewModel.viewData, target: updatedViewData),
using: StagedChangeset(source: viewModel.state.sections, target: updatedState.sections),
deleteSectionsAnimation: .none,
insertSectionsAnimation: .none,
reloadSectionsAnimation: .none,
deleteRowsAnimation: .bottom,
insertRowsAnimation: .top,
reloadRowsAnimation: .none,
interrupt: {
print("Interrupt change check: \($0.changeCount)")
return $0.changeCount > 100
} // Prevent too many changes from causing performance issues
) { [weak self] updatedData in
self?.viewModel.updateData(updatedData)
}
}
@objc private func handleProfileDidChangeNotification(_ notification: Notification) {
DispatchQueue.main.async {
self.tableView.reloadData() // TODO: Just reload the affected cell
}
}
@objc private func handleLocalProfileDidChangeNotification(_ notification: Notification) {
DispatchQueue.main.async {
self.updateNavBarButtons()
interrupt: { $0.changeCount > 100 } // Prevent too many changes from causing performance issues
) { [weak self] updatedSections in
guard let currentState: HomeViewModel.State = self?.viewModel.state else { return }
self?.viewModel.updateState(currentState.with(sections: updatedSections))
}
}
@objc private func handleSeedViewedNotification(_ notification: Notification) {
tableViewTopConstraint.isActive = false
tableViewTopConstraint = tableView.pin(.top, to: .top, of: view, withInset: Values.smallSpacing)
seedReminderView.removeFromSuperview()
}
@objc private func handleBlockedContactsUpdatedNotification(_ notification: Notification) {
self.tableView.reloadData() // TODO: Just reload the affected cell
self.viewModel.updateState(
self.viewModel.state.with(showViewedSeedBanner: updatedState.showViewedSeedBanner)
)
}
private func updateNavBarButtons() {
@ -358,15 +353,15 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
// MARK: - UITableViewDataSource
func numberOfSections(in tableView: UITableView) -> Int {
return viewModel.viewData.count
return viewModel.state.sections.count
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return viewModel.viewData[section].elements.count
return viewModel.state.sections[section].elements.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let section: ArraySection<Section, Item> = viewModel.viewData[indexPath.section]
let section: ArraySection<Section, Item> = viewModel.state.sections[indexPath.section]
switch section.model {
case .messageRequests:
@ -386,7 +381,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let section: ArraySection<Section, Item> = viewModel.viewData[indexPath.section]
let section: ArraySection<Section, Item> = viewModel.state.sections[indexPath.section]
switch section.model {
case .messageRequests:
@ -404,11 +399,11 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
}
func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
let section: ArraySection<Section, Item> = viewModel.viewData[indexPath.section]
let section: ArraySection<Section, Item> = viewModel.state.sections[indexPath.section]
switch section.model {
case .messageRequests:
let hide = UITableViewRowAction(style: .destructive, title: NSLocalizedString("TXT_HIDE_TITLE", comment: "")) { [weak self] _, _ in
let hide = UITableViewRowAction(style: .destructive, title: "TXT_HIDE_TITLE".localized()) { [weak self] _, _ in
GRDBStorage.shared.write { db in db[.hasHiddenMessageRequests] = true }
// Animate the row removal

71
Session/Home/HomeViewModel.swift

@ -11,8 +11,26 @@ public class HomeViewModel {
case threads
}
public struct State: Equatable {
let showViewedSeedBanner: Bool
let sections: [ArraySection<Section, SessionThreadViewModel>]
func with(
showViewedSeedBanner: Bool? = nil,
sections: [ArraySection<Section, SessionThreadViewModel>]? = nil
) -> State {
return State(
showViewedSeedBanner: (showViewedSeedBanner ?? self.showViewedSeedBanner),
sections: (sections ?? self.sections)
)
}
}
/// This value is the current state of the view
public private(set) var viewData: [ArraySection<Section, SessionThreadViewModel>] = []
public private(set) var state: State = State(
showViewedSeedBanner: !GRDBStorage.shared[.hasViewedSeed],
sections: []
)
/// 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
@ -21,7 +39,7 @@ public class HomeViewModel {
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
public lazy var observableViewData = ValueObservation
public lazy var observableState = ValueObservation
.tracking(
regions: [
// We explicitly define the regions we want to track as the automatic detection
@ -34,7 +52,10 @@ public class HomeViewModel {
.mutedUntilTimestamp,
.onlyNotifyForMentions
),
Setting.filter(id: Setting.BoolKey.hasHiddenMessageRequests.rawValue),
Setting.filter(ids: [
Setting.BoolKey.hasHiddenMessageRequests.rawValue,
Setting.BoolKey.hasViewedSeed.rawValue
]),
Contact.select(.isBlocked, .isApproved), // 'isApproved' for message requests
Profile.select(.name, .nickname, .profilePictureFileName),
ClosedGroup.select(.name),
@ -48,7 +69,8 @@ public class HomeViewModel {
RecipientState.select(.state),
ThreadTypingIndicator.select(.threadId)
],
fetch: { db -> [ArraySection<Section, SessionThreadViewModel>] in
fetch: { db -> State in
let hasViewedSeed: Bool = db[.hasViewedSeed]
let userPublicKey: String = getUserHexEncodedPublicKey(db)
let unreadMessageRequestCount: Int = try SessionThread
.unreadMessageRequestsCountQuery(userPublicKey: userPublicKey)
@ -59,31 +81,34 @@ public class HomeViewModel {
.homeQuery(userPublicKey: userPublicKey)
.fetchAll(db)
return [
ArraySection(
model: .messageRequests,
elements: [
// If there are no unread message requests then hide the message request banner
(finalUnreadMessageRequestCount == 0 ?
nil :
SessionThreadViewModel(
unreadCount: UInt(finalUnreadMessageRequestCount)
return State(
showViewedSeedBanner: !hasViewedSeed,
sections: [
ArraySection(
model: .messageRequests,
elements: [
// If there are no unread message requests then hide the message request banner
(finalUnreadMessageRequestCount == 0 ?
nil :
SessionThreadViewModel(
unreadCount: UInt(finalUnreadMessageRequestCount)
)
)
)
].compactMap { $0 }
),
ArraySection(
model: .threads,
elements: threads
)
]
].compactMap { $0 }
),
ArraySection(
model: .threads,
elements: threads
)
]
)
}
)
.removeDuplicates()
// MARK: - Functions
public func updateData(_ updatedData: [ArraySection<Section, SessionThreadViewModel>]) {
self.viewData = updatedData
public func updateState(_ updatedState: State) {
self.state = updatedState
}
}

14
Session/Notifications/AppNotifications.swift

@ -152,7 +152,8 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) {
guard Date().timeIntervalSince1970 < (thread.mutedUntilTimestamp ?? 0) else { return }
let isMessageRequest = thread.isMessageRequest(db)
let userPublicKey: String = getUserHexEncodedPublicKey(db)
let isMessageRequest: Bool = thread.isMessageRequest(db)
// If the thread is a message request and the user hasn't hidden message requests then we need
// to check if this is the only message request thread (group threads can't be message requests
@ -160,8 +161,10 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
// notification regardless of how many message requests there are)
if thread.variant == .contact {
if isMessageRequest && !db[.hasHiddenMessageRequests] {
let numMessageRequestThreads: Int? = try? SessionThread.messageRequestThreads(db)
.fetchCount(db)
let numMessageRequestThreads: Int? = (try? SessionThread
.messageRequestsCountQuery(userPublicKey: userPublicKey)
.fetchOne(db))
.defaulting(to: 0)
// Allow this to show a notification if there are no message requests (ie. this is the first one)
guard (numMessageRequestThreads ?? 0) == 0 else { return }
@ -244,7 +247,10 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
notificationBody = "MESSAGE_REQUESTS_NOTIFICATION".localized()
}
assert((notificationBody ?? notificationTitle) != nil)
guard notificationBody != nil || notificationTitle != nil else {
SNLog("AppNotifications error: No notification content")
return
}
// Don't reply from lockscreen if anyone in this conversation is
// "no longer verified".

4
Session/Onboarding/Onboarding.swift

@ -28,7 +28,7 @@ enum Onboarding {
switch self {
case .register:
userDefaults[.hasViewedSeed] = false
GRDBStorage.shared.write { db in db[.hasViewedSeed] = false }
// Set hasSyncedInitialConfiguration to true so that when we hit the
// home screen a configuration sync is triggered (yes, the logic is a
// bit weird). This is needed so that if the user registers and
@ -37,7 +37,7 @@ enum Onboarding {
case .recover, .link:
// No need to show it again if the user is restoring or linking
userDefaults[.hasViewedSeed] = true
GRDBStorage.shared.write { db in db[.hasViewedSeed] = true }
userDefaults[.hasSyncedInitialConfiguration] = false
}

6
Session/Onboarding/SeedReminderView.swift

@ -1,5 +1,9 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
final class SeedReminderView : UIView {
import UIKit
import SessionUIKit
final class SeedReminderView: UIView {
private let hasContinueButton: Bool
var title = NSAttributedString(string: "") { didSet { titleLabel.attributedText = title } }
var subtitle = "" { didSet { subtitleLabel.text = subtitle } }

4
Session/Onboarding/SeedVC.swift

@ -170,8 +170,8 @@ final class SeedVC: BaseVC {
self.seedReminderView.subtitle = NSLocalizedString("view_seed_reminder_subtitle_3", comment: "")
}, completion: nil)
seedReminderView.setProgress(1, animated: true)
UserDefaults.standard[.hasViewedSeed] = true
NotificationCenter.default.post(name: .seedViewed, object: nil)
GRDBStorage.shared.write { db in db[.hasViewedSeed] = true }
}
@objc private func copyMnemonic() {

4
Session/Settings/SeedModal.swift

@ -71,9 +71,9 @@ final class SeedModal: Modal {
stackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
contentView.pin(.trailing, to: .trailing, of: stackView, withInset: Values.largeSpacing)
contentView.pin(.bottom, to: .bottom, of: stackView, withInset: Values.largeSpacing)
// Mark seed as viewed
UserDefaults.standard[.hasViewedSeed] = true
NotificationCenter.default.post(name: .seedViewed, object: nil)
GRDBStorage.shared.write { db in db[.hasViewedSeed] = true }
}
// MARK: Interaction

1
SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift

@ -75,6 +75,7 @@ public enum SMKLegacy {
internal static let soundsGlobalNotificationKey = "kOWSSoundsStorageGlobalNotificationKey"
internal static let userDefaultsHasHiddenMessageRequests = "hasHiddenMessageRequests"
internal static let userDefaultsHasViewedSeedKey = "hasViewedSeed"
// MARK: - DatabaseMigration

4
SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift

@ -335,8 +335,10 @@ enum _001_InitialSetupMigration: Migration {
try db.create(table: ThreadTypingIndicator.self) { t in
t.column(.threadId, .text)
.primaryKey()
.references(SessionThread.self, onDelete: .cascade) // Delete if thread deleted
.references(SessionThread.self, onDelete: .cascade) // Delete if thread deleted
t.column(.timestampMs, .integer).notNull()
}
GRDBStorage.shared.update(progress: 1, for: self, in: target) // In case this is the last migration
}
}

2
SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift

@ -51,5 +51,7 @@ enum _002_SetupStandardJobs: Migration {
behaviour: .recurringOnLaunch
).inserted(db)
}
GRDBStorage.shared.update(progress: 1, for: self, in: target) // In case this is the last migration
}
}

5
SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift

@ -1295,9 +1295,14 @@ enum _003_YDBToGRDBMigration: Migration {
db[.areLinkPreviewsEnabled] = (legacyPreferences[SMKLegacy.preferencesKeyAreLinkPreviewsEnabled] as? Bool == true)
db[.hasHiddenMessageRequests] = CurrentAppContext().appUserDefaults()
.bool(forKey: SMKLegacy.userDefaultsHasHiddenMessageRequests)
// Note: The 'hasViewedSeed' was originally stored on standard user defaults
db[.hasViewedSeed] = UserDefaults.standard.bool(forKey: SMKLegacy.userDefaultsHasViewedSeedKey)
db[.hasSavedThread] = (legacyPreferences[SMKLegacy.preferencesKeyHasSavedThreadKey] as? Bool == true)
db[.hasSentAMessage] = (legacyPreferences[SMKLegacy.preferencesKeyHasSentAMessageKey] as? Bool == true)
db[.isReadyForAppExtensions] = CurrentAppContext().appUserDefaults().bool(forKey: SMKLegacy.preferencesKeyIsReadyForAppExtensions)
GRDBStorage.shared.update(progress: 1, for: self, in: target) // In case this is the last migration
}
// MARK: - Convenience

11
SessionMessagingKit/Database/Models/Interaction.swift

@ -27,12 +27,13 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu
/// Whenever using this `linkPreview` association make sure to filter the result using
/// `.filter(literal: Interaction.linkPreviewFilterLiteral)` to ensure the correct LinkPreview is returned
public static let linkPreview = hasOne(LinkPreview.self, using: LinkPreview.interactionForeignKey)
public static let linkPreviewFilterLiteral: SQL = {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
public static func linkPreviewFilterLiteral(
timestampColumn: SQL = SQL(stringLiteral: Interaction.Columns.timestampMs.name)
) -> SQL {
let linkPreview: TypedTableAlias<LinkPreview> = TypedTableAlias()
return "(ROUND((\(interaction[.timestampMs]) / 1000 / 100000) - 0.5) * 100000) = \(linkPreview[.timestamp])"
}()
return "(ROUND((\(Interaction.self).\(timestampColumn) / 1000 / 100000) - 0.5) * 100000) = \(linkPreview[.timestamp])"
}
public static let recipientStates = hasMany(RecipientState.self, using: RecipientState.interactionForeignKey)
public typealias Columns = CodingKeys
@ -354,7 +355,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu
.aliased(interactionAlias)
.joining(
required: Interaction.linkPreview
.filter(literal: Interaction.linkPreviewFilterLiteral)
.filter(literal: Interaction.linkPreviewFilterLiteral())
)
.fetchCount(db)
let tmp = try linkPreview.fetchAll(db)

10
SessionMessagingKit/Database/Models/Profile.swift

@ -90,14 +90,8 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco
}
}
// Since it's possible this profile is currently being displayed, send notifications
// indicating that it has been updated
NotificationCenter.default.post(name: .profileUpdated, object: id)
if id == getUserHexEncodedPublicKey(db) {
NotificationCenter.default.post(name: .localProfileDidChange, object: nil)
}
else {
// FIXME: Remove this once the OWSConversationSettingsViewController has been refactored and is observing DB changes
if id != getUserHexEncodedPublicKey(db) {
let userInfo = [ Notification.Key.profileRecipientId.rawValue: id ]
NotificationCenter.default.post(name: .otherUsersProfileDidChange, object: nil, userInfo: userInfo)
}

43
SessionMessagingKit/Database/Models/RecipientState.swift

@ -22,20 +22,34 @@ public struct RecipientState: Codable, Equatable, FetchableRecord, PersistableRe
}
public enum State: Int, Codable, Hashable, DatabaseValueConvertible {
case failed
/// These cases **MUST** remain in this order (even though having `failed` as `0` would be more logical) as the order
/// is optimised for the desired "interactionState" grouping behaviour we want which makes the query to retrieve the interaction
/// state run ~16 times than the alternate approach which required a sub-query (check git history to see the old approach at the
/// bottom of this file if desired)
///
/// The expected behaviour of the grouped "interactionState" that both the `SessionThreadViewModel` and
/// `MessageViewModel` should use is `IFNULL(MIN("recipientState"."state"), 'sending')` (joining on the
/// `interaction.id` and `state != 'skipped'`):
/// - The 'skipped' state should be ignored entirely
/// - If there is no state (ie. interaction recipient records not yet created) then the interaction state should be 'sending'
/// - If there is a single 'sending' state then the interaction state should be 'sending'
/// - If there is a single 'failed' state and no 'sending' state then the interaction state should be 'failed'
/// - If there are neither 'sending' or 'failed' states then the interaction state should be 'sent'
case sending
case failed
case skipped
case sent
func message(hasAttachments: Bool, hasAtLeastOneReadReceipt: Bool) -> String {
switch self {
case .failed: return "MESSAGE_STATUS_FAILED".localized()
case .sending:
guard hasAttachments else {
return "MESSAGE_STATUS_SENDING".localized()
}
return "MESSAGE_STATUS_UPLOADING".localized()
case .failed: return "MESSAGE_STATUS_FAILED".localized()
case .sent:
guard hasAtLeastOneReadReceipt else {
@ -117,28 +131,3 @@ public extension RecipientState {
)
}
}
// MARK: - GRDB Queries
public extension RecipientState {
static func selectInteractionState(tableLiteral: SQL, idColumnLiteral: SQL) -> SQL {
let recipientState: TypedTableAlias<RecipientState> = TypedTableAlias()
return """
SELECT * FROM (
SELECT
\(recipientState[.interactionId]),
\(recipientState[.state]),
\(recipientState[.mostRecentFailureText])
FROM \(RecipientState.self)
WHERE \(SQL("\(recipientState[.state]) != \(RecipientState.State.skipped)")) -- Ignore 'skipped'
ORDER BY
-- If there is a single 'sending' then should be 'sending', otherwise if there is a single
-- 'failed' and there is no 'sending' then it should be 'failed'
\(SQL("\(recipientState[.state]) = \(RecipientState.State.sending)")) DESC,
\(SQL("\(recipientState[.state]) = \(RecipientState.State.failed)")) DESC
) AS \(tableLiteral)
GROUP BY \(tableLiteral).\(idColumnLiteral)
"""
}
}

63
SessionMessagingKit/Database/Models/SessionThread.swift

@ -185,17 +185,6 @@ public extension SessionThread {
return existingThread
}
static func messageRequestThreads(_ db: Database) -> QueryInterfaceRequest<SessionThread> {
return SessionThread
.filter(Columns.shouldBeVisible == true)
.filter(Columns.variant == Variant.contact)
.filter(Columns.id != getUserHexEncodedPublicKey(db))
.joining(
optional: contact
.filter(Contact.Columns.isApproved == false)
)
}
func isMessageRequest(_ db: Database) -> Bool {
return (
shouldBeVisible &&
@ -209,23 +198,38 @@ public extension SessionThread {
// MARK: - Convenience
public extension SessionThread {
static func messageRequestsCountQuery(userPublicKey: String) -> SQLRequest<Int> {
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let contact: TypedTableAlias<Contact> = TypedTableAlias()
return """
SELECT COUNT(\(thread[.id]))
FROM \(SessionThread.self)
LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])
WHERE (
\(SessionThread.isMessageRequest(userPublicKey: userPublicKey))
)
"""
}
static func unreadMessageRequestsCountQuery(userPublicKey: String) -> SQLRequest<Int> {
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let contact: TypedTableAlias<Contact> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let unreadInteractionLiteral: SQL = SQL(stringLiteral: "unreadInteraction")
let interactionThreadIdColumnLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name)
return """
SELECT COUNT(\(thread[.id]))
FROM \(SessionThread.self)
JOIN (
SELECT \(interaction[.threadId])
SELECT
\(interaction[.threadId]),
MIN(\(interaction[.wasRead])) AS \(SQL(stringLiteral: "\(Interaction.Columns.wasRead.name)"))
FROM \(Interaction.self)
WHERE \(interaction[.wasRead]) = false
GROUP BY \(interaction[.threadId])
) AS \(unreadInteractionLiteral) ON \(unreadInteractionLiteral).\(interactionThreadIdColumnLiteral) = \(thread[.id])
) AS \(Interaction.self) ON (
\(interaction[.threadId]) = \(thread[.id]) AND
\(interaction[.wasRead]) = false
)
LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])
WHERE (
\(SessionThread.isMessageRequest(userPublicKey: userPublicKey))
@ -244,32 +248,13 @@ public extension SessionThread {
return SQL(
"""
\(thread[.shouldBeVisible]) = true AND
\(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND
\(SQL("\(thread[.id]) != \(userPublicKey)")) AND (
/* Note: A '!= true' check doesn't work properly so we need to be explicit */
\(contact[.isApproved]) IS NULL OR
\(contact[.isApproved]) = false
)
\(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND
\(SQL("\(thread[.id]) != \(userPublicKey)")) AND
IFNULL(\(contact[.isApproved]), false) = false
"""
)
}
/// This method can be used to filter a thread query to exclude messages requests
///
/// **Note:** In order to use this filter you **MUST** have a `joining(required/optional:)` to the
/// `SessionThread.contact` association or it won't work
static func isNotMessageRequest(userPublicKey: String) -> SQLSpecificExpressible {
let contactAlias: TypedTableAlias<Contact> = TypedTableAlias()
return (
SessionThread.Columns.shouldBeVisible == true && (
SessionThread.Columns.variant != SessionThread.Variant.contact ||
SessionThread.Columns.id == userPublicKey || // Note to self
contactAlias[.isApproved] == true
)
)
}
func isNoteToSelf(_ db: Database? = nil) -> Bool {
return (
variant == .contact &&

4
SessionMessagingKit/Database/Notification+Contacts.swift

@ -4,15 +4,11 @@ import SessionUtilitiesKit
public extension Notification.Name {
static let profileUpdated = Notification.Name("profileUpdated")
static let localProfileDidChange = Notification.Name("localProfileDidChange")
static let otherUsersProfileDidChange = Notification.Name("otherUsersProfileDidChange")
}
@objc public extension NSNotification {
@objc static let profileUpdated = Notification.Name.profileUpdated.rawValue as NSString
@objc static let localProfileDidChange = Notification.Name.localProfileDidChange.rawValue as NSString
@objc static let otherUsersProfileDidChange = Notification.Name.otherUsersProfileDidChange.rawValue as NSString
}

46
SessionMessagingKit/Shared Models/MessageViewModel.swift

@ -75,8 +75,8 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
public let state: RecipientState.State
public let hasAtLeastOneReadReceipt: Bool
public let mostRecentFailureText: String?
public let isTypingIndicator: Bool
public let isSenderOpenGroupModerator: Bool
public let isTypingIndicator: Bool?
public let profile: Profile?
public let quote: Quote?
public let quoteAttachment: Attachment?
@ -169,7 +169,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
isLast: Bool
) -> MessageViewModel {
let cellType: CellType = {
guard !self.isTypingIndicator else { return .typingIndicator }
guard self.isTypingIndicator != true else { return .typingIndicator }
guard self.variant != .standardIncomingDeleted else { return .textOnlyMessage }
guard let attachment: Attachment = self.attachments?.first else { return .textOnlyMessage }
@ -208,7 +208,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
nickname: nil // Folded into 'authorName' within the Query
)
let shouldShowDateOnThisModel: Bool = {
guard !self.isTypingIndicator else { return false }
guard self.isTypingIndicator != true else { return false }
guard let prevModel: ViewModel = prevModel else { return true }
return MessageViewModel.shouldShowDateBreak(
@ -218,7 +218,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
}()
let shouldShowDateOnNextModel: Bool = {
// Should be nothing after a typing indicator
guard !self.isTypingIndicator else { return false }
guard self.isTypingIndicator != true else { return false }
guard let nextModel: ViewModel = nextModel else { return false }
return MessageViewModel.shouldShowDateBreak(
@ -404,18 +404,21 @@ public extension MessageViewModel {
// MARK: - Convenience Initialization
public extension MessageViewModel {
public static let genericId: Int64 = -2
public static let typingIndicatorId: Int64 = -2
static let genericId: Int64 = -2
static let typingIndicatorId: Int64 = -2
// Note: This init method is only used system-created cells or empty states
init(isTypingIndicator: Bool = false) {
init(isTypingIndicator: Bool? = nil) {
self.threadVariant = .contact
self.threadIsTrusted = false
self.threadHasDisappearingMessagesEnabled = false
// Interaction Info
let targetId: Int64 = (isTypingIndicator ? MessageViewModel.typingIndicatorId : MessageViewModel.genericId)
let targetId: Int64 = (isTypingIndicator == true ?
MessageViewModel.typingIndicatorId :
MessageViewModel.genericId
)
self.rowId = targetId
self.id = targetId
self.variant = .standardOutgoing
@ -513,7 +516,7 @@ public extension MessageViewModel {
return { additionalFilters, limitSQL -> AdaptedFetchRequest<SQLRequest<ViewModel>> in
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let typingIndicator: TypedTableAlias<ThreadTypingIndicator> = TypedTableAlias()
let recipientState: TypedTableAlias<RecipientState> = TypedTableAlias()
let contact: TypedTableAlias<Contact> = TypedTableAlias()
let disappearingMessagesConfig: TypedTableAlias<DisappearingMessagesConfiguration> = TypedTableAlias()
let profile: TypedTableAlias<Profile> = TypedTableAlias()
@ -522,7 +525,6 @@ public extension MessageViewModel {
let interactionStateTableLiteral: SQL = SQL(stringLiteral: "interactionState")
let interactionStateInteractionIdColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.interactionId.name)
let interactionStateStateColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.state.name)
let interactionStateMostRecentFailureTextColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.mostRecentFailureText.name)
let readReceiptTableLiteral: SQL = SQL(stringLiteral: "readReceipt")
let readReceiptReadTimestampMsColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.readTimestampMs.name)
@ -543,7 +545,7 @@ public extension MessageViewModel {
"""
}()
let finalLimitSQL: SQL = (limitSQL ?? SQL(stringLiteral: ""))
let numColumnsBeforeLinkedRecords: Int = 17
let numColumnsBeforeLinkedRecords: Int = 16
let request: SQLRequest<ViewModel> = """
SELECT
\(thread[.variant]) AS \(ViewModel.threadVariantKey),
@ -563,11 +565,10 @@ public extension MessageViewModel {
\(interaction[.expiresInSeconds]),
-- Default to 'sending' assuming non-processed interaction when null
IFNULL(\(interactionStateTableLiteral).\(interactionStateStateColumnLiteral), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.stateKey),
IFNULL(MIN(\(recipientState[.state])), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.stateKey),
(\(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL) AS \(ViewModel.hasAtLeastOneReadReceiptKey),
\(interactionStateTableLiteral).\(interactionStateMostRecentFailureTextColumnLiteral) AS \(ViewModel.mostRecentFailureTextKey),
(\(typingIndicator[.threadId]) IS NOT NULL) AS \(ViewModel.isTypingIndicatorKey),
\(recipientState[.mostRecentFailureText]) AS \(ViewModel.mostRecentFailureTextKey),
false AS \(ViewModel.isSenderOpenGroupModeratorKey),
\(ViewModel.profileKey).*,
@ -587,7 +588,6 @@ public extension MessageViewModel {
FROM \(Interaction.self)
JOIN \(SessionThread.self) ON \(thread[.id]) = \(interaction[.threadId])
LEFT JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id])
LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId])
LEFT JOIN \(DisappearingMessagesConfiguration.self) ON \(disappearingMessagesConfig[.threadId]) = \(interaction[.threadId])
LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId])
@ -595,20 +595,20 @@ public extension MessageViewModel {
LEFT JOIN \(Attachment.self) AS \(ViewModel.quoteAttachmentKey) ON \(ViewModel.quoteAttachmentKey).\(attachmentIdColumnLiteral) = \(quote[.attachmentId])
LEFT JOIN \(LinkPreview.self) ON (
\(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND
\(Interaction.linkPreviewFilterLiteral)
\(Interaction.linkPreviewFilterLiteral())
)
LEFT JOIN \(Attachment.self) AS \(ViewModel.linkPreviewAttachmentKey) ON \(ViewModel.linkPreviewAttachmentKey).\(attachmentIdColumnLiteral) = \(linkPreview[.attachmentId])
LEFT JOIN (
\(RecipientState.selectInteractionState(
tableLiteral: interactionStateTableLiteral,
idColumnLiteral: interactionStateInteractionIdColumnLiteral
))
) AS \(interactionStateTableLiteral) ON \(interactionStateTableLiteral).\(interactionStateInteractionIdColumnLiteral) = \(interaction[.id])
LEFT JOIN \(RecipientState.self) ON (
-- Ignore 'skipped' states
\(SQL("\(recipientState[.state]) != \(RecipientState.State.skipped)")) AND
\(recipientState[.interactionId]) = \(interaction[.id])
)
LEFT JOIN \(RecipientState.self) AS \(readReceiptTableLiteral) ON (
\(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL AND
\(interaction[.id]) = \(readReceiptTableLiteral).\(interactionStateInteractionIdColumnLiteral)
)
\(finalFilterSQL)
GROUP BY \(interaction[.id])
ORDER BY \(orderSQL)
\(finalLimitSQL)
"""

170
SessionMessagingKit/Shared Models/SessionThreadViewModel.swift

@ -47,6 +47,7 @@ public struct SessionThreadViewModel: FetchableRecord, Decodable, Equatable, Has
public static let interactionVariantKey: SQL = SQL(stringLiteral: CodingKeys.interactionVariant.stringValue)
public static let interactionTimestampMsKey: SQL = SQL(stringLiteral: CodingKeys.interactionTimestampMs.stringValue)
public static let interactionBodyKey: SQL = SQL(stringLiteral: CodingKeys.interactionBody.stringValue)
public static let interactionStateKey: SQL = SQL(stringLiteral: CodingKeys.interactionState.stringValue)
public static let interactionIsOpenGroupInvitationKey: SQL = SQL(stringLiteral: CodingKeys.interactionIsOpenGroupInvitation.stringValue)
public static let interactionAttachmentDescriptionInfoKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachmentDescriptionInfo.stringValue)
public static let interactionAttachmentCountKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachmentCount.stringValue)
@ -262,27 +263,17 @@ public extension SessionThreadViewModel {
let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let recipientState: TypedTableAlias<RecipientState> = TypedTableAlias()
let linkPreview: TypedTableAlias<LinkPreview> = TypedTableAlias()
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
let profile: TypedTableAlias<Profile> = TypedTableAlias()
let unreadCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.threadUnreadCountString)_table")
let unreadMentionCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.threadUnreadMentionCountString)_table")
let interactionThreadIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name)
let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name)
let profileNicknameColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.nickname.name)
let profileNameColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.name.name)
let authorProfileLiteral: SQL = SQL(stringLiteral: "authorProfile")
let attachmentIdColumnLiteral: SQL = SQL(stringLiteral: Attachment.Columns.id.name)
let attachmentVariantColumnLiteral: SQL = SQL(stringLiteral: Attachment.Columns.variant.name)
let attachmentContentTypeColumnLiteral: SQL = SQL(stringLiteral: Attachment.Columns.contentType.name)
let attachmentSourceFilenameColumnLiteral: SQL = SQL(stringLiteral: Attachment.Columns.sourceFilename.name)
let firstInteractionAttachmentLiteral: SQL = SQL(stringLiteral: "firstInteractionAttachment")
let interactionAttachmentAttachmentIdColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.attachmentId.name)
let interactionAttachmentInteractionIdColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.interactionId.name)
let interactionAttachmentAlbumIndexColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.albumIndex.name)
let interactionStateTableLiteral: SQL = SQL(stringLiteral: "interactionState")
let interactionStateInteractionIdColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.interactionId.name)
let interactionStateStateColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.state.name)
/// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before
/// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to
@ -291,7 +282,6 @@ public extension SessionThreadViewModel {
/// Explicitly set default values for the fields ignored for search results
let numColumnsBeforeProfiles: Int = 11
let numColumnsBetweenProfilesAndAttachmentInfo: Int = 10 // The attachment info columns will be combined
// TODO: Some testing around the subqueries in the joins to see if they impact performance ('Simulator1' device takes ~125ms to complete this query)
let request: SQLRequest<ViewModel> = """
SELECT
@ -306,8 +296,8 @@ public extension SessionThreadViewModel {
\(thread[.onlyNotifyForMentions]) AS \(ViewModel.threadOnlyNotifyForMentionsKey),
(\(typingIndicator[.threadId]) IS NOT NULL) AS \(ViewModel.threadContactIsTypingKey),
\(unreadCountTableLiteral).\(ViewModel.threadUnreadCountKey) AS \(ViewModel.threadUnreadCountKey),
\(unreadMentionCountTableLiteral).\(ViewModel.threadUnreadMentionCountKey) AS \(ViewModel.threadUnreadMentionCountKey),
\(Interaction.self).\(ViewModel.threadUnreadCountKey),
\(Interaction.self).\(ViewModel.threadUnreadMentionCountKey),
\(ViewModel.contactProfileKey).*,
\(ViewModel.closedGroupProfileFrontKey).*,
@ -318,59 +308,75 @@ public extension SessionThreadViewModel {
\(openGroup[.name]) AS \(ViewModel.openGroupNameKey),
\(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey),
\(interaction[.id]) AS \(ViewModel.interactionIdKey),
\(interaction[.variant]) AS \(ViewModel.interactionVariantKey),
\(interaction[.timestampMs]) AS \(ViewModel.interactionTimestampMsKey),
\(interaction[.body]) AS \(ViewModel.interactionBodyKey),
\(Interaction.self).\(ViewModel.interactionIdKey),
\(Interaction.self).\(ViewModel.interactionVariantKey),
\(Interaction.self).\(ViewModel.interactionTimestampMsKey),
\(Interaction.self).\(ViewModel.interactionBodyKey),
-- Default to 'sending' assuming non-processed interaction when null
IFNULL(\(interactionStateTableLiteral).\(interactionStateStateColumnLiteral), \(SQL("\(RecipientState.State.sending)"))) AS \(interactionStateTableLiteral),
IFNULL(MIN(\(recipientState[.state])), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.interactionStateKey),
(\(linkPreview[.url]) IS NOT NULL) AS \(ViewModel.interactionIsOpenGroupInvitationKey),
\(ViewModel.interactionAttachmentDescriptionInfoKey).\(attachmentIdColumnLiteral),
\(ViewModel.interactionAttachmentDescriptionInfoKey).\(attachmentVariantColumnLiteral),
\(ViewModel.interactionAttachmentDescriptionInfoKey).\(attachmentContentTypeColumnLiteral),
\(ViewModel.interactionAttachmentDescriptionInfoKey).\(attachmentSourceFilenameColumnLiteral),
-- These 4 properties will be combined into 'Attachment.DescriptionInfo'
\(attachment[.id]),
\(attachment[.variant]),
\(attachment[.contentType]),
\(attachment[.sourceFilename]),
COUNT(\(interactionAttachment[.interactionId])) AS \(ViewModel.interactionAttachmentCountKey),
\(interaction[.authorId]),
IFNULL(\(authorProfileLiteral).\(profileNicknameColumnLiteral), \(authorProfileLiteral).\(profileNameColumnLiteral)) AS \(ViewModel.authorNameInternalKey),
IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.authorNameInternalKey),
\(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey)
FROM \(SessionThread.self)
LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])
LEFT JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id])
LEFT JOIN (
SELECT *, MAX(\(interaction[.timestampMs]))
FROM \(Interaction.self)
GROUP BY \(interaction[.threadId])