Fixed build issues, bugs, added unit tests and added the ConvoInfoVolatile handling

Added the unit tests for the ConvoInfoVolatile
Added icons to the swipe actions
Updated jobs to be able to be prioritised (and added priorities to the launch jobs to avoid some odd behaviours)
Fixed some build issues resulting from merging
Fixed an issue with the open group pubkey encoding
Fixed an issue where an imageView could get it's image set on a background thread
Fixed a bug where the swipe actions weren't getting theming applied when the theme changed
Fixed a bug where scheduling code after the next db transaction completes couldn't be nested (resulting in code not running)
Fixed a bug where the PagedDataObserver might not notify of unobserved changes if they reverted previous unobserved changes
Fixed a couple of incorrect SQL ordering use cases (was overriding instead of appending ordering)
Fixed an issue where the app would re-upload the avatar every launch (only affected this branch)
Fixed an issue where the home screen wouldn't update group avatars when their profile data changed
This commit is contained in:
Morgan Pretty 2023-02-01 18:12:36 +11:00
parent 07046db4b6
commit 345b693225
74 changed files with 1451 additions and 290 deletions

View File

@ -752,6 +752,9 @@
FDB4BBC92839BEF000B7C95D /* ProfileManagerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB4BBC82839BEF000B7C95D /* ProfileManagerError.swift */; };
FDB7400B28EB99A70094D718 /* TimeInterval+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB7400A28EB99A70094D718 /* TimeInterval+Utilities.swift */; };
FDB7400D28EBEC240094D718 /* DateHeaderCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB7400C28EBEC240094D718 /* DateHeaderCell.swift */; };
FDBB25E12983909300F1508E /* ConfigConvoInfoVolatileSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDBB25E02983909300F1508E /* ConfigConvoInfoVolatileSpec.swift */; };
FDBB25E32988B13800F1508E /* _004_AddJobPriority.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDBB25E22988B13800F1508E /* _004_AddJobPriority.swift */; };
FDBB25E72988BBBE00F1508E /* UIContextualAction+Theming.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDBB25E62988BBBD00F1508E /* UIContextualAction+Theming.swift */; };
FDC2908727D7047F005DAE71 /* RoomSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2908627D7047F005DAE71 /* RoomSpec.swift */; };
FDC2908927D70656005DAE71 /* RoomPollInfoSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2908827D70656005DAE71 /* RoomPollInfoSpec.swift */; };
FDC2908B27D707F3005DAE71 /* SendMessageRequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC2908A27D707F3005DAE71 /* SendMessageRequestSpec.swift */; };
@ -1866,6 +1869,9 @@
FDB4BBC82839BEF000B7C95D /* ProfileManagerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileManagerError.swift; sourceTree = "<group>"; };
FDB7400A28EB99A70094D718 /* TimeInterval+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+Utilities.swift"; sourceTree = "<group>"; };
FDB7400C28EBEC240094D718 /* DateHeaderCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateHeaderCell.swift; sourceTree = "<group>"; };
FDBB25E02983909300F1508E /* ConfigConvoInfoVolatileSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigConvoInfoVolatileSpec.swift; sourceTree = "<group>"; };
FDBB25E22988B13800F1508E /* _004_AddJobPriority.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _004_AddJobPriority.swift; sourceTree = "<group>"; };
FDBB25E62988BBBD00F1508E /* UIContextualAction+Theming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIContextualAction+Theming.swift"; sourceTree = "<group>"; };
FDC2908627D7047F005DAE71 /* RoomSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSpec.swift; sourceTree = "<group>"; };
FDC2908827D70656005DAE71 /* RoomPollInfoSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPollInfoSpec.swift; sourceTree = "<group>"; };
FDC2908A27D707F3005DAE71 /* SendMessageRequestSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageRequestSpec.swift; sourceTree = "<group>"; };
@ -2881,6 +2887,7 @@
B885D5F52334A32100EE0D8E /* UIView+Constraints.swift */,
C33100272559000A00070591 /* UIView+Utilities.swift */,
FD71161F28D97ABC00B47552 /* UIImage+Tinting.swift */,
FDBB25E62988BBBD00F1508E /* UIContextualAction+Theming.swift */,
);
path = Utilities;
sourceTree = "<group>";
@ -3695,6 +3702,7 @@
FD17D7C927F546D900122BE0 /* _001_InitialSetupMigration.swift */,
FD9004132818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift */,
FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */,
FDBB25E22988B13800F1508E /* _004_AddJobPriority.swift */,
);
path = Migrations;
sourceTree = "<group>";
@ -4034,6 +4042,7 @@
children = (
FD2B4AFA29429D1000AB4848 /* ConfigContactsSpec.swift */,
FD8ECF812934387A00C0D1BB /* ConfigUserProfileSpec.swift */,
FDBB25E02983909300F1508E /* ConfigConvoInfoVolatileSpec.swift */,
);
path = LibSessionUtil;
sourceTree = "<group>";
@ -5301,6 +5310,7 @@
FD37E9CF28A1EB1B003AE748 /* Theme.swift in Sources */,
C331FFB92558FA8D00070591 /* UIView+Constraints.swift in Sources */,
FD37E9F628A5F106003AE748 /* Configuration.swift in Sources */,
FDBB25E72988BBBE00F1508E /* UIContextualAction+Theming.swift in Sources */,
C331FFE02558FB0000070591 /* SearchBar.swift in Sources */,
FD71162C28E1451400B47552 /* Position.swift in Sources */,
FD52090328B4680F006098F6 /* RadioButton.swift in Sources */,
@ -5487,6 +5497,7 @@
FD17D7C127F5200100122BE0 /* TypedTableDefinition.swift in Sources */,
FD17D7EA27F6A1C600122BE0 /* SUKLegacy.swift in Sources */,
FDA8EB10280F8238002B68E5 /* Codable+Utilities.swift in Sources */,
FDBB25E32988B13800F1508E /* _004_AddJobPriority.swift in Sources */,
C352A36D2557858E00338F3E /* NSTimer+Proxying.m in Sources */,
7B7CB192271508AD0079FF93 /* CallRingTonePlayer.swift in Sources */,
C3C2ABD22553C6C900C340D1 /* Data+SecureRandom.swift in Sources */,
@ -6017,6 +6028,7 @@
FDC290B327DFF9F5005DAE71 /* TestOnionRequestAPI.swift in Sources */,
FDC2909127D709CA005DAE71 /* SOGSMessageSpec.swift in Sources */,
FD3C906A27E417CE00CD579F /* SodiumUtilitiesSpec.swift in Sources */,
FDBB25E12983909300F1508E /* ConfigConvoInfoVolatileSpec.swift in Sources */,
FD3C907127E445E500CD579F /* MessageReceiverDecryptionSpec.swift in Sources */,
FDC2909627D71252005DAE71 /* SOGSErrorSpec.swift in Sources */,
FDC2908727D7047F005DAE71 /* RoomSpec.swift in Sources */,

View File

@ -244,12 +244,26 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
return adminIds.contains(userPublicKey)
}
func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) {
UIContextualAction.willBeginEditing(indexPath: indexPath, tableView: tableView)
}
func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) {
UIContextualAction.didEndEditing(indexPath: indexPath, tableView: tableView)
}
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let profileId: String = self.membersAndZombies[indexPath.row].profileId
let delete: UIContextualAction = UIContextualAction(
style: .destructive,
title: "GROUP_ACTION_REMOVE".localized()
title: "GROUP_ACTION_REMOVE".localized(),
icon: UIImage(named: "icon_bin"),
themeTintColor: .textPrimary,
themeBackgroundColor: .conversationButton_swipeDestructive,
side: .trailing,
actionIndex: 0,
indexPath: indexPath,
tableView: tableView
) { [weak self] _, _, completionHandler in
self?.adminIds.remove(profileId)
self?.membersAndZombies.remove(at: indexPath.row)
@ -257,7 +271,6 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
completionHandler(true)
}
delete.themeBackgroundColor = .conversationButton_swipeDestructive
return UISwipeActionsConfiguration(actions: [ delete ])
}
@ -286,7 +299,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
}
private func handleMembersChanged() {
tableViewHeightConstraint.constant = CGFloat(membersAndZombies.count) * 67
tableViewHeightConstraint.constant = CGFloat(membersAndZombies.count) * 72
tableView.reloadData()
}

View File

@ -84,7 +84,7 @@ extension ConversationSearchController: UISearchResultsUpdating {
let threadId: String = self.threadId
DispatchQueue.global(qos: .default).async { [weak self] in
let results: [Int64]? = Storage.shared.read { db -> [Int64] in
let results: [Interaction.TimestampInfo]? = Storage.shared.read { db -> [Interaction.TimestampInfo] in
self?.resultsBar.willStartSearching(readConnection: db)
return try Interaction.idsForTermWithin(
@ -97,7 +97,7 @@ extension ConversationSearchController: UISearchResultsUpdating {
// If we didn't get results back then we most likely interrupted the query so
// should ignore the results (if there are no results we would succeed and get
// an empty array back)
guard let results: [Int64] = results else { return }
guard let results: [Interaction.TimestampInfo] = results else { return }
DispatchQueue.main.async {
guard let strongSelf = self else { return }
@ -116,11 +116,11 @@ extension ConversationSearchController: SearchResultsBarDelegate {
func searchResultsBar(
_ searchResultsBar: SearchResultsBar,
setCurrentIndex currentIndex: Int,
results: [Int64]
results: [Interaction.TimestampInfo]
) {
guard let interactionId: Int64 = results[safe: currentIndex] else { return }
guard let interactionInfo: Interaction.TimestampInfo = results[safe: currentIndex] else { return }
self.delegate?.conversationSearchController(self, didSelectInteractionId: interactionId)
self.delegate?.conversationSearchController(self, didSelectInteractionInfo: interactionInfo)
}
}
@ -128,13 +128,13 @@ protocol SearchResultsBarDelegate: AnyObject {
func searchResultsBar(
_ searchResultsBar: SearchResultsBar,
setCurrentIndex currentIndex: Int,
results: [Int64]
results: [Interaction.TimestampInfo]
)
}
public final class SearchResultsBar: UIView {
private var readConnection: Atomic<Database?> = Atomic(nil)
private var results: Atomic<[Int64]?> = Atomic(nil)
private var results: Atomic<[Interaction.TimestampInfo]?> = Atomic(nil)
var currentIndex: Int?
weak var resultsBarDelegate: SearchResultsBarDelegate?
@ -249,7 +249,7 @@ public final class SearchResultsBar: UIView {
// MARK: - Actions
@objc public func handleUpButtonTapped() {
guard let results: [Int64] = results.wrappedValue else { return }
guard let results: [Interaction.TimestampInfo] = results.wrappedValue else { return }
guard let currentIndex: Int = currentIndex else { return }
guard currentIndex + 1 < results.count else { return }
@ -261,7 +261,7 @@ public final class SearchResultsBar: UIView {
@objc public func handleDownButtonTapped() {
Logger.debug("")
guard let results: [Int64] = results.wrappedValue else { return }
guard let results: [Interaction.TimestampInfo] = results.wrappedValue else { return }
guard let currentIndex: Int = currentIndex, currentIndex > 0 else { return }
let newIndex = currentIndex - 1
@ -288,12 +288,12 @@ public final class SearchResultsBar: UIView {
self.readConnection.mutate { $0 = readConnection }
}
func updateResults(results: [Int64]?) {
func updateResults(results: [Interaction.TimestampInfo]?) {
// We want to ignore search results that don't match the current searchId (this
// will happen when searching large threads with short terms as the shorter terms
// will take much longer to resolve than the longer terms)
currentIndex = {
guard let results: [Int64] = results, !results.isEmpty else { return nil }
guard let results: [Interaction.TimestampInfo] = results, !results.isEmpty else { return nil }
if let currentIndex: Int = currentIndex {
return max(0, min(currentIndex, results.count - 1))
@ -313,7 +313,7 @@ public final class SearchResultsBar: UIView {
}
func updateBarItems() {
guard let results: [Int64] = results.wrappedValue else {
guard let results: [Interaction.TimestampInfo] = results.wrappedValue else {
label.text = ""
downButton.isEnabled = false
upButton.isEnabled = false
@ -363,6 +363,6 @@ public final class SearchResultsBar: UIView {
// MARK: - ConversationSearchControllerDelegate
public protocol ConversationSearchControllerDelegate: UISearchControllerDelegate {
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults results: [Int64]?, searchText: String?)
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionId: Int64)
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults results: [Interaction.TimestampInfo]?, searchText: String?)
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionInfo: Interaction.TimestampInfo)
}

View File

@ -998,16 +998,18 @@ extension ConversationVC:
case .textOnlyMessage:
if let quote: Quote = cellViewModel.quote {
// Scroll to the original quoted message
let maybeOriginalInteractionId: Int64? = Storage.shared.read { db in
let maybeOriginalInteractionInfo: Interaction.TimestampInfo? = Storage.shared.read { db in
try quote.originalInteraction
.select(.id)
.asRequest(of: Int64.self)
.select(.id, .timestampMs)
.asRequest(of: Interaction.TimestampInfo.self)
.fetchOne(db)
}
guard let interactionId: Int64 = maybeOriginalInteractionId else { return }
guard let interactionInfo: Interaction.TimestampInfo = maybeOriginalInteractionInfo else {
return
}
self.scrollToInteractionIfNeeded(with: interactionId, highlight: true)
self.scrollToInteractionIfNeeded(with: interactionInfo, highlight: true)
}
else if let linkPreview: LinkPreview = cellViewModel.linkPreview {
switch linkPreview.variant {

View File

@ -25,7 +25,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
/// never have disappeared before - this is only needed for value observers since they run asynchronously)
private var hasReloadedThreadDataAfterDisappearance: Bool = true
var focusedInteractionId: Int64?
var focusedInteractionInfo: Interaction.TimestampInfo?
var shouldHighlightNextScrollToInteraction: Bool = false
// Search
@ -331,8 +331,8 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
// MARK: - Initialization
init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionId: Int64? = nil) {
self.viewModel = ConversationViewModel(threadId: threadId, threadVariant: threadVariant, focusedInteractionId: focusedInteractionId)
init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionInfo: Interaction.TimestampInfo? = nil) {
self.viewModel = ConversationViewModel(threadId: threadId, threadVariant: threadVariant, focusedInteractionInfo: focusedInteractionInfo)
Storage.shared.addObserver(viewModel.pagedDataObserver)
@ -436,6 +436,12 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
name: UIApplication.userDidTakeScreenshotNotification,
object: nil
)
// The first time the view loads we should mark the thread as read (in case it was manually
// marked as unread) - doing this here means if we add a "mark as unread" action within the
// conversation settings then we don't need to worry about the conversation getting marked as
// when when the user returns back through this view controller
self.viewModel.markAsRead(target: .thread, timestampMs: nil)
}
override func viewWillAppear(_ animated: Bool) {
@ -832,8 +838,8 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
// Animate to the target interaction (or the bottom) after a slightly delay to prevent buggy
// animation conflicts
if let focusedInteractionId: Int64 = self.focusedInteractionId {
// If we had a focusedInteractionId then scroll to it (and hide the search
if let focusedInteractionInfo: Interaction.TimestampInfo = self.focusedInteractionInfo {
// If we had a focusedInteractionInfo then scroll to it (and hide the search
// result bar loading indicator)
let delay: DispatchTime = (didSwapAllContent ?
.now() :
@ -843,7 +849,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
DispatchQueue.main.asyncAfter(deadline: delay) { [weak self] in
self?.searchController.resultsBar.stopLoading()
self?.scrollToInteractionIfNeeded(
with: focusedInteractionId,
with: focusedInteractionInfo,
isAnimated: true,
highlight: (self?.shouldHighlightNextScrollToInteraction == true)
)
@ -915,13 +921,13 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
self?.tableView.contentOffset.y += oldCellTopOffset
}
if let focusedInteractionId: Int64 = self?.focusedInteractionId {
if let focusedInteractionInfo: Interaction.TimestampInfo = self?.focusedInteractionInfo {
DispatchQueue.main.async { [weak self] in
// If we had a focusedInteractionId then scroll to it (and hide the search
// If we had a focusedInteractionInfo then scroll to it (and hide the search
// result bar loading indicator)
self?.searchController.resultsBar.stopLoading()
self?.scrollToInteractionIfNeeded(
with: focusedInteractionId,
with: focusedInteractionInfo,
isAnimated: true,
highlight: (self?.shouldHighlightNextScrollToInteraction == true)
)
@ -935,13 +941,13 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
)
}
else if wasLoadingMore {
if let focusedInteractionId: Int64 = self.focusedInteractionId {
if let focusedInteractionInfo: Interaction.TimestampInfo = self.focusedInteractionInfo {
DispatchQueue.main.async { [weak self] in
// If we had a focusedInteractionId then scroll to it (and hide the search
// If we had a focusedInteractionInfo then scroll to it (and hide the search
// result bar loading indicator)
self?.searchController.resultsBar.stopLoading()
self?.scrollToInteractionIfNeeded(
with: focusedInteractionId,
with: focusedInteractionInfo,
isAnimated: true,
highlight: (self?.shouldHighlightNextScrollToInteraction == true)
)
@ -983,8 +989,8 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
// Scroll to the last unread message if possible; otherwise scroll to the bottom.
// When the unread message count is more than the number of view items of a page,
// the screen will scroll to the bottom instead of the first unread message
if let focusedInteractionId: Int64 = self.viewModel.focusedInteractionId {
self.scrollToInteractionIfNeeded(with: focusedInteractionId, isAnimated: false, highlight: true)
if let focusedInteractionInfo: Interaction.TimestampInfo = self.viewModel.focusedInteractionInfo {
self.scrollToInteractionIfNeeded(with: focusedInteractionInfo, isAnimated: false, highlight: true)
}
else {
self.scrollToBottom(isAnimated: false)
@ -1385,11 +1391,22 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
guard !self.didFinishInitialLayout || !hasNewerItems else {
let messages: [MessageViewModel] = self.viewModel.interactionData[messagesSectionIndex].elements
let lastInteractionId: Int64 = self.viewModel.threadData.interactionId
.defaulting(to: messages[messages.count - 1].id)
let lastInteractionInfo: Interaction.TimestampInfo = {
guard
let interactionId: Int64 = self.viewModel.threadData.interactionId,
let timestampMs: Int64 = self.viewModel.threadData.interactionTimestampMs
else {
return Interaction.TimestampInfo(
id: messages[messages.count - 1].id,
timestampMs: messages[messages.count - 1].timestampMs
)
}
return Interaction.TimestampInfo(id: interactionId, timestampMs: timestampMs)
}()
self.scrollToInteractionIfNeeded(
with: lastInteractionId,
with: lastInteractionInfo,
position: .bottom,
isJumpingToLastInteraction: true,
isAnimated: true
@ -1416,7 +1433,10 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
animated: isAnimated
)
self.viewModel.markAsRead(beforeInclusive: nil)
self.viewModel.markAsRead(
target: .threadAndInteractions(interactionsBeforeInclusive: nil),
timestampMs: nil
)
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
@ -1461,22 +1481,25 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
.last?
.cellViewModel
{
self.viewModel.markAsRead(beforeInclusive: newestCellViewModel.id)
self.viewModel.markAsRead(
target: .threadAndInteractions(interactionsBeforeInclusive: newestCellViewModel.id),
timestampMs: newestCellViewModel.timestampMs
)
}
}
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
guard
let focusedInteractionId: Int64 = self.focusedInteractionId,
let focusedInteractionInfo: Interaction.TimestampInfo = self.focusedInteractionInfo,
self.shouldHighlightNextScrollToInteraction
else {
self.focusedInteractionId = nil
self.focusedInteractionInfo = nil
self.shouldHighlightNextScrollToInteraction = false
return
}
DispatchQueue.main.async { [weak self] in
self?.highlightCellIfNeeded(interactionId: focusedInteractionId)
self?.highlightCellIfNeeded(interactionId: focusedInteractionInfo.id)
}
}
@ -1590,26 +1613,29 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
hideSearchUI()
}
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults results: [Int64]?, searchText: String?) {
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults results: [Interaction.TimestampInfo]?, searchText: String?) {
viewModel.lastSearchedText = searchText
tableView.reloadRows(at: tableView.indexPathsForVisibleRows ?? [], with: UITableView.RowAnimation.none)
}
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionId interactionId: Int64) {
scrollToInteractionIfNeeded(with: interactionId, highlight: true)
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionInfo interactionInfo: Interaction.TimestampInfo) {
scrollToInteractionIfNeeded(with: interactionInfo, highlight: true)
}
func scrollToInteractionIfNeeded(
with interactionId: Int64,
with interactionInfo: Interaction.TimestampInfo,
position: UITableView.ScrollPosition = .middle,
isJumpingToLastInteraction: Bool = false,
isAnimated: Bool = true,
highlight: Bool = false
) {
// Store the info incase we need to load more data (call will be re-triggered)
self.focusedInteractionId = interactionId
self.focusedInteractionInfo = interactionInfo
self.shouldHighlightNextScrollToInteraction = highlight
self.viewModel.markAsRead(beforeInclusive: interactionId)
self.viewModel.markAsRead(
target: .threadAndInteractions(interactionsBeforeInclusive: interactionInfo.id),
timestampMs: interactionInfo.timestampMs
)
// Ensure the target interaction has been loaded
guard
@ -1617,7 +1643,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
.firstIndex(where: { $0.model == .messages }),
let targetMessageIndex = self.viewModel.interactionData[messageSectionIndex]
.elements
.firstIndex(where: { $0.id == interactionId })
.firstIndex(where: { $0.id == interactionInfo.id })
else {
// If not the make sure we have finished the initial layout before trying to
// load the up until the specified interaction
@ -1629,13 +1655,13 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
if isJumpingToLastInteraction {
self?.viewModel.pagedDataObserver?.load(.jumpTo(
id: interactionId,
id: interactionInfo.id,
paddingForInclusive: 5
))
}
else {
self?.viewModel.pagedDataObserver?.load(.untilInclusive(
id: interactionId,
id: interactionInfo.id,
padding: 5
))
}
@ -1671,12 +1697,12 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
// so it doesn't look buggy with the push transition
if highlight {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(self.didFinishInitialLayout ? 0 : 150)) { [weak self] in
self?.highlightCellIfNeeded(interactionId: interactionId)
self?.highlightCellIfNeeded(interactionId: interactionInfo.id)
}
}
self.shouldHighlightNextScrollToInteraction = false
self.focusedInteractionId = nil
self.focusedInteractionInfo = nil
return
}
@ -1687,7 +1713,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
let targetRect: CGRect = self.tableView.rectForRow(at: targetIndexPath)
guard !self.tableView.bounds.contains(targetRect) else {
self.highlightCellIfNeeded(interactionId: interactionId)
self.highlightCellIfNeeded(interactionId: interactionInfo.id)
return
}
@ -1696,7 +1722,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
func highlightCellIfNeeded(interactionId: Int64) {
self.shouldHighlightNextScrollToInteraction = false
self.focusedInteractionId = nil
self.focusedInteractionInfo = nil
// Trigger on the next run loop incase we are still finishing some other animation
DispatchQueue.main.async {

View File

@ -34,7 +34,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
public let initialThreadVariant: SessionThread.Variant
public var sentMessageBeforeUpdate: Bool = false
public var lastSearchedText: String?
public let focusedInteractionId: Int64? // Note: This is used for global search
public let focusedInteractionInfo: Interaction.TimestampInfo? // Note: This is used for global search
public lazy var blockedBannerMessage: String = {
switch self.threadData.threadVariant {
@ -52,28 +52,30 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
// MARK: - Initialization
init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionId: Int64?) {
init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionInfo: Interaction.TimestampInfo?) {
// If we have a specified 'focusedInteractionId' then use that, otherwise retrieve the oldest
// unread interaction and start focused around that one
let targetInteractionId: Int64? = {
if let focusedInteractionId: Int64 = focusedInteractionId { return focusedInteractionId }
let targetInteractionInfo: Interaction.TimestampInfo? = {
if let focusedInteractionInfo: Interaction.TimestampInfo = focusedInteractionInfo {
return focusedInteractionInfo
}
return Storage.shared.read { db in
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
return try Interaction
.select(.id)
.select(.id, .timestampMs)
.filter(interaction[.wasRead] == false)
.filter(interaction[.threadId] == threadId)
.order(interaction[.timestampMs].asc)
.asRequest(of: Int64.self)
.asRequest(of: Interaction.TimestampInfo.self)
.fetchOne(db)
}
}()
self.threadId = threadId
self.initialThreadVariant = threadVariant
self.focusedInteractionId = targetInteractionId
self.focusedInteractionInfo = targetInteractionInfo
self.pagedDataObserver = nil
// Note: Since this references self we need to finish initializing before setting it, we
@ -89,12 +91,12 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
// If we don't have a `initialFocusedId` then default to `.pageBefore` (it'll query
// from a `0` offset)
guard let initialFocusedId: Int64 = targetInteractionId else {
guard let initialFocusedInfo: Interaction.TimestampInfo = targetInteractionInfo else {
self?.pagedDataObserver?.load(.pageBefore)
return
}
self?.pagedDataObserver?.load(.initialPageAround(id: initialFocusedId))
self?.pagedDataObserver?.load(.initialPageAround(id: initialFocusedInfo.id))
}
}
@ -150,7 +152,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
// MARK: - Interaction Data
private var lastInteractionIdMarkedAsRead: Int64?
private var lastInteractionTimestampMsMarkedAsRead: Int64 = 0
public private(set) var unobservedInteractionDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)?
public private(set) var interactionData: [SectionModel] = []
public private(set) var reactionExpandedInteractionIds: Set<Int64> = []
@ -186,7 +188,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let contact: TypedTableAlias<Contact> = TypedTableAlias()
return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId])")
return SQL("JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId])")
}()
),
PagedData.ObservedChanges(
@ -196,7 +198,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let profile: TypedTableAlias<Profile> = TypedTableAlias()
return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId])")
return SQL("JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId])")
}()
)
],
@ -253,7 +255,10 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
currentDataRetriever: { self?.interactionData },
onDataChange: self?.onInteractionChange,
onUnobservedDataChange: { updatedData, changeset in
self?.unobservedInteractionDataChanges = (updatedData, changeset)
self?.unobservedInteractionDataChanges = (changeset.isEmpty ?
nil :
(updatedData, changeset)
)
}
)
}
@ -389,36 +394,34 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
}
}
/// This method will mark all interactions as read before the specified interaction id, if no id is provided then all interactions for
/// the thread will be marked as read
public func markAsRead(beforeInclusive interactionId: Int64?) {
/// This method marks a thread as read and depending on the target may also update the interactions within a thread as read
public func markAsRead(
target: SessionThreadViewModel.ReadTarget,
timestampMs: Int64?
) {
/// Since this method now gets triggered when scrolling we want to try to optimise it and avoid busying the database
/// write queue when it isn't needed, in order to do this we:
/// write queue when it isn't needed, in order to do this we don't bother marking anything as read if this was called with
/// the same `interactionId` that we previously marked as read (ie. when scrolling and the last message hasn't changed)
///
/// - Don't bother marking anything as read if there are no unread interactions (we can rely on the
/// `threadData.threadUnreadCount` to always be accurate)
/// - Don't bother marking anything as read if this was called with the same `interactionId` that we
/// previously marked as read (ie. when scrolling and the last message hasn't changed)
guard
(self.threadData.threadUnreadCount ?? 0) > 0,
let targetInteractionId: Int64 = (interactionId ?? self.threadData.interactionId),
self.lastInteractionIdMarkedAsRead != targetInteractionId
else { return }
let threadId: String = self.threadData.threadId
let threadVariant: SessionThread.Variant = self.threadData.threadVariant
let trySendReadReceipt: Bool = (self.threadData.threadIsMessageRequest == false)
self.lastInteractionIdMarkedAsRead = targetInteractionId
Storage.shared.writeAsync { db in
try Interaction.markAsRead(
db,
interactionId: targetInteractionId,
threadId: threadId,
threadVariant: threadVariant,
includingOlder: true,
trySendReadReceipt: trySendReadReceipt
)
/// The `ThreadViewModel.markAsRead` method also tries to avoid marking as read if a conversation is already fully read
switch target {
case .thread: self.threadData.markAsRead(target: target)
case .threadAndInteractions:
guard
timestampMs == nil ||
self.lastInteractionTimestampMsMarkedAsRead < (timestampMs ?? 0)
else {
self.threadData.markAsRead(target: .thread)
return
}
// If we were given a timestamp then update the 'lastInteractionTimestampMsMarkedAsRead'
// to avoid needless updates
if let timestampMs: Int64 = timestampMs {
self.lastInteractionTimestampMsMarkedAsRead = timestampMs
}
self.threadData.markAsRead(target: target)
}
}

View File

@ -207,7 +207,9 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
self?.termForCurrentSearchResultSet = searchText
self?.searchResultSet = [
(hasResults ? nil : [ArraySection(model: .noResults, elements: [SessionThreadViewModel(unreadCount: 0)])]),
(hasResults ? nil : [
ArraySection(model: .noResults, elements: [SessionThreadViewModel()])
]),
(hasResults ? sections : nil)
]
.compactMap { $0 }
@ -271,15 +273,25 @@ extension GlobalSearchViewController {
show(
threadId: section.elements[indexPath.row].threadId,
threadVariant: section.elements[indexPath.row].threadVariant,
focusedInteractionId: section.elements[indexPath.row].interactionId
focusedInteractionInfo: {
guard
let interactionId: Int64 = section.elements[indexPath.row].interactionId,
let timestampMs: Int64 = section.elements[indexPath.row].interactionTimestampMs
else { return nil }
return Interaction.TimestampInfo(
id: interactionId,
timestampMs: timestampMs
)
}()
)
}
}
private func show(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionId: Int64? = nil, animated: Bool = true) {
private func show(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionInfo: Interaction.TimestampInfo? = nil, animated: Bool = true) {
guard Thread.isMainThread else {
DispatchQueue.main.async { [weak self] in
self?.show(threadId: threadId, threadVariant: threadVariant, focusedInteractionId: focusedInteractionId, animated: animated)
self?.show(threadId: threadId, threadVariant: threadVariant, focusedInteractionInfo: focusedInteractionInfo, animated: animated)
}
return
}
@ -292,7 +304,7 @@ extension GlobalSearchViewController {
.viewControllers)
.defaulting(to: [])
.appending(
ConversationVC(threadId: threadId, threadVariant: threadVariant, focusedInteractionId: focusedInteractionId)
ConversationVC(threadId: threadId, threadVariant: threadVariant, focusedInteractionInfo: focusedInteractionInfo)
)
self.navigationController?.setViewControllers(viewControllers, animated: true)

View File

@ -610,7 +610,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
variant: threadViewModel.threadVariant,
isMessageRequest: (threadViewModel.threadIsMessageRequest == true),
with: .none,
focusedInteractionId: nil,
focusedInteractionInfo: nil,
animated: true
)
@ -622,25 +622,102 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
return true
}
func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) {
UIContextualAction.willBeginEditing(indexPath: indexPath, tableView: tableView)
}
func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) {
UIContextualAction.didEndEditing(indexPath: indexPath, tableView: tableView)
}
func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let section: HomeViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
let unswipeAnimationDelay: DispatchTimeInterval = .milliseconds(500)
switch section.model {
case .threads:
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
let isUnread: Bool = (
threadViewModel.threadWasMarkedUnread == true ||
(threadViewModel.threadUnreadCount ?? 0) > 0
)
let changeReadStatus: UIContextualAction = UIContextualAction(
title: (isUnread ?
"MARK_AS_READ".localized() :
"MARK_AS_UNREAD".localized()
),
icon: (isUnread ?
UIImage(systemName: "envelope.open") :
UIImage(systemName: "envelope.badge")
),
themeTintColor: .textPrimary,
themeBackgroundColor: .conversationButton_swipeRead,
side: .leading,
actionIndex: 0,
indexPath: indexPath,
tableView: tableView
) { [weak self] _, _, completionHandler in
// Delay the change to give the cell "unswipe" animation some time to complete
DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) {
switch isUnread {
case true:
self?.viewModel.markAsRead(
threadViewModel: threadViewModel,
target: .threadAndInteractions(
interactionsBeforeInclusive: threadViewModel.interactionId
)
)
case false:
self?.viewModel.markAsUnread(threadViewModel: threadViewModel)
}
}
completionHandler(true)
}
return UISwipeActionsConfiguration(actions: [changeReadStatus])
default: return nil
}
}
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let section: HomeViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
let unswipeAnimationDelay: DispatchTimeInterval = .milliseconds(500)
switch section.model {
case .messageRequests:
let hide: UIContextualAction = UIContextualAction(style: .destructive, title: "TXT_HIDE_TITLE".localized()) { _, _, completionHandler in
let hide: UIContextualAction = UIContextualAction(
title: "TXT_HIDE_TITLE".localized(),
icon: UIImage(systemName: "eye.slash"),
themeTintColor: .textPrimary,
themeBackgroundColor: .conversationButton_swipeDestructive,
side: .trailing,
actionIndex: 0,
indexPath: indexPath,
tableView: tableView
) { _, _, completionHandler in
Storage.shared.write { db in db[.hasHiddenMessageRequests] = true }
completionHandler(true)
}
hide.themeBackgroundColor = .conversationButton_swipeDestructive
return UISwipeActionsConfiguration(actions: [hide])
case .threads:
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
let shouldHaveBlockAction: Bool = (
threadViewModel.threadVariant == .contact &&
!threadViewModel.threadIsNoteToSelf
)
let delete: UIContextualAction = UIContextualAction(
style: .destructive,
title: "TXT_DELETE_TITLE".localized()
title: "TXT_DELETE_TITLE".localized(),
icon: UIImage(named: "icon_bin"),
themeTintColor: .textPrimary,
themeBackgroundColor: .conversationButton_swipeDestructive,
side: .trailing,
actionIndex: 2,
indexPath: indexPath,
tableView: tableView
) { [weak self] _, _, completionHandler in
let confirmationModal: ConfirmationModal = ConfirmationModal(
info: ConfirmationModal.Info(
@ -668,14 +745,22 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
self?.present(confirmationModal, animated: true, completion: nil)
}
delete.themeBackgroundColor = .conversationButton_swipeDestructive
let pin: UIContextualAction = UIContextualAction(
style: .normal,
title: (threadViewModel.threadIsPinned ?
"UNPIN_BUTTON_TEXT".localized() :
"PIN_BUTTON_TEXT".localized()
)
),
icon: (threadViewModel.threadIsPinned ?
UIImage(systemName: "pin.slash") :
UIImage(systemName: "pin")
),
themeTintColor: .textPrimary,
themeBackgroundColor: .conversationButton_swipeTertiary,
side: .trailing,
actionIndex: (shouldHaveBlockAction ? 0 : 1),
indexPath: indexPath,
tableView: tableView
) { _, _, completionHandler in
(tableView.cellForRow(at: indexPath) as? FullConversationCell)?.optimisticUpdate(
isPinned: !threadViewModel.threadIsPinned
@ -691,18 +776,23 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
}
}
}
pin.themeBackgroundColor = .conversationButton_swipeTertiary
guard threadViewModel.threadVariant == .contact && !threadViewModel.threadIsNoteToSelf else {
guard shouldHaveBlockAction else {
return UISwipeActionsConfiguration(actions: [ delete, pin ])
}
let block: UIContextualAction = UIContextualAction(
style: .normal,
title: (threadViewModel.threadIsBlocked == true ?
"BLOCK_LIST_UNBLOCK_BUTTON".localized() :
"BLOCK_LIST_BLOCK_BUTTON".localized()
)
),
icon: UIImage(named: "table_ic_block"),
themeTintColor: .textPrimary,
themeBackgroundColor: .conversationButton_swipeSecondary,
side: .trailing,
actionIndex: 1,
indexPath: indexPath,
tableView: tableView
) { _, _, completionHandler in
(tableView.cellForRow(at: indexPath) as? FullConversationCell)?.optimisticUpdate(
isBlocked: (threadViewModel.threadIsBlocked == false)
@ -728,7 +818,6 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
.sinkUntilComplete()
}
}
block.themeBackgroundColor = .conversationButton_swipeSecondary
return UISwipeActionsConfiguration(actions: [ delete, block, pin ])
@ -749,7 +838,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
variant: SessionThread.Variant,
isMessageRequest: Bool,
with action: ConversationViewModel.Action,
focusedInteractionId: Int64?,
focusedInteractionInfo: Interaction.TimestampInfo?,
animated: Bool
) {
if let presentedVC = self.presentedViewController {
@ -762,7 +851,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
ConversationVC(
threadId: threadId,
threadVariant: variant,
focusedInteractionId: focusedInteractionId
focusedInteractionInfo: focusedInteractionInfo
)
].compactMap { $0 }

View File

@ -64,7 +64,8 @@ public class HomeViewModel {
.shouldBeVisible,
.isPinned,
.mutedUntilTimestamp,
.onlyNotifyForMentions
.onlyNotifyForMentions,
.markedAsUnread
]
),
PagedData.ObservedChanges(
@ -76,7 +77,7 @@ public class HomeViewModel {
joinToPagedType: {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
return SQL("LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])")
return SQL("JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
@ -85,7 +86,7 @@ public class HomeViewModel {
joinToPagedType: {
let contact: TypedTableAlias<Contact> = TypedTableAlias()
return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])")
return SQL("JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
@ -93,8 +94,53 @@ public class HomeViewModel {
columns: [.name, .nickname, .profilePictureFileName],
joinToPagedType: {
let profile: TypedTableAlias<Profile> = TypedTableAlias()
let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
let threadVariants: [SessionThread.Variant] = [.legacyClosedGroup, .closedGroup]
let targetRole: GroupMember.Role = GroupMember.Role.standard
return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(thread[.id])")
return SQL("""
JOIN \(Profile.self) ON (
( -- Contact profile change
\(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND
\(profile[.id]) = \(thread[.id])
) OR ( -- Closed group profile change
\(SQL("\(thread[.variant]) IN \(threadVariants)")) AND (
profile.id = ( -- Front profile
SELECT MIN(\(groupMember[.profileId]))
FROM \(GroupMember.self)
JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId])
WHERE (
\(SQL("\(groupMember[.role]) = \(targetRole)")) AND
\(groupMember[.groupId]) = \(thread[.id]) AND
\(groupMember[.profileId]) != \(userPublicKey)
)
) OR
profile.id = ( -- Back profile
SELECT MAX(\(groupMember[.profileId]))
FROM \(GroupMember.self)
JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId])
WHERE (
\(SQL("\(groupMember[.role]) = \(targetRole)")) AND
\(groupMember[.groupId]) = \(thread[.id]) AND
\(groupMember[.profileId]) != \(userPublicKey)
)
) OR ( -- Fallback profile
profile.id = \(userPublicKey) AND
(
SELECT COUNT(\(groupMember[.profileId]))
FROM \(GroupMember.self)
JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId])
WHERE (
\(SQL("\(groupMember[.role]) = \(targetRole)")) AND
\(groupMember[.groupId]) = \(thread[.id]) AND
\(groupMember[.profileId]) != \(userPublicKey)
)
) = 1
)
)
)
)
""")
}()
),
PagedData.ObservedChanges(
@ -103,7 +149,7 @@ public class HomeViewModel {
joinToPagedType: {
let closedGroup: TypedTableAlias<ClosedGroup> = TypedTableAlias()
return SQL("LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id])")
return SQL("JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
@ -112,7 +158,7 @@ public class HomeViewModel {
joinToPagedType: {
let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
return SQL("LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id])")
return SQL("JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
@ -123,8 +169,8 @@ public class HomeViewModel {
let recipientState: TypedTableAlias<RecipientState> = TypedTableAlias()
return """
LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])
LEFT JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id])
JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])
JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id])
"""
}()
),
@ -134,7 +180,7 @@ public class HomeViewModel {
joinToPagedType: {
let typingIndicator: TypedTableAlias<ThreadTypingIndicator> = TypedTableAlias()
return SQL("LEFT JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id])")
return SQL("JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id])")
}()
)
],
@ -155,7 +201,10 @@ public class HomeViewModel {
currentDataRetriever: { self?.threadData },
onDataChange: self?.onThreadChange,
onUnobservedDataChange: { updatedData, changeset in
self?.unobservedThreadDataChanges = (updatedData, changeset)
self?.unobservedThreadDataChanges = (changeset.isEmpty ?
nil :
(updatedData, changeset)
)
}
)
}
@ -223,8 +272,11 @@ public class HomeViewModel {
updatedData: updatedThreadData,
currentDataRetriever: { [weak self] in (self?.unobservedThreadDataChanges?.0 ?? self?.threadData) },
onDataChange: onThreadChange,
onUnobservedDataChange: { [weak self] updatedThreadData, changeset in
self?.unobservedThreadDataChanges = (updatedThreadData, changeset)
onUnobservedDataChange: { [weak self] updatedData, changeset in
self?.unobservedThreadDataChanges = (changeset.isEmpty ?
nil :
(updatedData, changeset)
)
}
)
}
@ -301,6 +353,17 @@ public class HomeViewModel {
// MARK: - Functions
public func markAsRead(
threadViewModel: SessionThreadViewModel,
target: SessionThreadViewModel.ReadTarget
) {
threadViewModel.markAsRead(target: target)
}
public func markAsUnread(threadViewModel: SessionThreadViewModel) {
threadViewModel.markAsUnread()
}
public func delete(threadId: String, threadVariant: SessionThread.Variant) {
Storage.shared.writeAsync { db in
switch threadVariant {

View File

@ -390,6 +390,14 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
return true
}
func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) {
UIContextualAction.willBeginEditing(indexPath: indexPath, tableView: tableView)
}
func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) {
UIContextualAction.didEndEditing(indexPath: indexPath, tableView: tableView)
}
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
@ -398,8 +406,14 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
let threadId: String = section.elements[indexPath.row].threadId
let threadVariant: SessionThread.Variant = section.elements[indexPath.row].threadVariant
let delete: UIContextualAction = UIContextualAction(
style: .destructive,
title: "TXT_DELETE_TITLE".localized()
title: "TXT_DELETE_TITLE".localized(),
icon: UIImage(named: "icon_bin"),
themeTintColor: .textPrimary,
themeBackgroundColor: .conversationButton_swipeDestructive,
side: .trailing,
actionIndex: (threadVariant == .contact ? 1 : 0),
indexPath: indexPath,
tableView: tableView
) { [weak self] _, _, completionHandler in
MessageRequestsViewModel.deleteMessageRequest(
threadId: threadId,
@ -408,13 +422,18 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
)
completionHandler(true)
}
delete.themeBackgroundColor = .conversationButton_swipeDestructive
switch threadVariant {
case .contact:
let block: UIContextualAction = UIContextualAction(
style: .normal,
title: "BLOCK_LIST_BLOCK_BUTTON".localized()
title: "BLOCK_LIST_BLOCK_BUTTON".localized(),
icon: UIImage(named: "table_ic_block"),
themeTintColor: .textPrimary,
themeBackgroundColor: .conversationButton_swipeDestructive,
side: .trailing,
actionIndex: 0,
indexPath: indexPath,
tableView: tableView
) { [weak self] _, _, completionHandler in
MessageRequestsViewModel.blockMessageRequest(
threadId: threadId,
@ -423,7 +442,6 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
)
completionHandler(true)
}
block.themeBackgroundColor = .conversationButton_swipeSecondary
return UISwipeActionsConfiguration(actions: [ delete, block ])

View File

@ -51,7 +51,7 @@ public class MessageRequestsViewModel {
joinToPagedType: {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
return SQL("LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])")
return SQL("JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
@ -60,7 +60,7 @@ public class MessageRequestsViewModel {
joinToPagedType: {
let contact: TypedTableAlias<Contact> = TypedTableAlias()
return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])")
return SQL("JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
@ -69,7 +69,7 @@ public class MessageRequestsViewModel {
joinToPagedType: {
let profile: TypedTableAlias<Profile> = TypedTableAlias()
return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(thread[.id])")
return SQL("JOIN \(Profile.self) ON \(profile[.id]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
@ -80,8 +80,8 @@ public class MessageRequestsViewModel {
let recipientState: TypedTableAlias<RecipientState> = TypedTableAlias()
return """
LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])
LEFT JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id])
JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])
JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id])
"""
}()
)
@ -103,7 +103,10 @@ public class MessageRequestsViewModel {
currentDataRetriever: { self?.threadData },
onDataChange: self?.onThreadChange,
onUnobservedDataChange: { updatedData, changeset in
self?.unobservedThreadDataChanges = (updatedData, changeset)
self?.unobservedThreadDataChanges = (changeset.isEmpty ?
nil :
(updatedData, changeset)
)
}
)
}

View File

@ -98,7 +98,10 @@ public class MediaGalleryViewModel {
currentDataRetriever: { self?.galleryData },
onDataChange: self?.onGalleryChange,
onUnobservedDataChange: { updatedData, changeset in
self?.unobservedGalleryDataChanges = (updatedData, changeset)
self?.unobservedGalleryDataChanges = (changeset.isEmpty ?
nil :
(updatedData, changeset)
)
}
)
}

View File

@ -390,7 +390,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
viewModel.observableAlbumData,
onError: { _ in },
onChange: { [weak self] albumData in
// The defaul scheduler emits changes on the main thread
// The default scheduler emits changes on the main thread
self?.handleUpdates(albumData)
}
)
@ -922,7 +922,9 @@ extension MediaGalleryViewModel.Item: GalleryRailItem {
imageView.contentMode = .scaleAspectFill
self.thumbnailImage { [weak imageView] image in
imageView?.image = image
DispatchQueue.main.async {
imageView?.image = image
}
}
return imageView

View File

@ -27,7 +27,7 @@ public struct SessionApp {
threadVariant: variant,
isMessageRequest: isMessageRequest,
action: action,
focusInteractionId: nil,
focusInteractionInfo: nil,
animated: animated
)
}
@ -37,7 +37,7 @@ public struct SessionApp {
threadVariant: SessionThread.Variant,
isMessageRequest: Bool,
action: ConversationViewModel.Action,
focusInteractionId: Int64?,
focusInteractionInfo: Interaction.TimestampInfo?,
animated: Bool
) {
guard Thread.isMainThread else {
@ -47,7 +47,7 @@ public struct SessionApp {
threadVariant: threadVariant,
isMessageRequest: isMessageRequest,
action: action,
focusInteractionId: focusInteractionId,
focusInteractionInfo: focusInteractionInfo,
animated: animated
)
}
@ -59,7 +59,7 @@ public struct SessionApp {
variant: threadVariant,
isMessageRequest: isMessageRequest,
with: action,
focusedInteractionId: focusInteractionId,
focusedInteractionInfo: focusInteractionInfo,
animated: animated
)
}

View File

@ -602,3 +602,5 @@
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";

View File

@ -602,3 +602,5 @@
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";

View File

@ -602,3 +602,5 @@
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";

View File

@ -602,3 +602,5 @@
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";

View File

@ -602,3 +602,5 @@
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";

View File

@ -602,3 +602,5 @@
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";

View File

@ -602,3 +602,5 @@
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";

View File

@ -602,3 +602,5 @@
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";

View File

@ -602,3 +602,5 @@
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";

View File

@ -602,3 +602,5 @@
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";

View File

@ -602,3 +602,5 @@
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";

View File

@ -602,3 +602,5 @@
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";

View File

@ -602,3 +602,5 @@
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";

View File

@ -602,3 +602,5 @@
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";

View File

@ -602,3 +602,5 @@
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";

View File

@ -602,3 +602,5 @@
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";

View File

@ -602,3 +602,5 @@
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";

View File

@ -602,3 +602,5 @@
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";

View File

@ -602,3 +602,5 @@
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";

View File

@ -602,3 +602,5 @@
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";

View File

@ -602,3 +602,5 @@
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";

View File

@ -602,3 +602,5 @@
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
"REMOVE_AVATAR" = "Remove";
"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name";
"MARK_AS_READ" = "Mark Read";
"MARK_AS_UNREAD" = "Mark Unread";

View File

@ -197,7 +197,7 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC
threadVariant: .openGroup,
isMessageRequest: false,
action: .compose,
focusInteractionId: nil,
focusInteractionInfo: nil,
animated: false
)
}

View File

@ -525,7 +525,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
avatarUpdate: .remove,
success: { db in
// Wait for the database transaction to complete before updating the UI
db.afterNextTransaction { _ in
db.afterNextTransactionNested { _ in
DispatchQueue.main.async {
modalActivityIndicator.dismiss(completion: {})
}
@ -564,7 +564,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
avatarUpdate: avatarUpdate,
success: { db in
// Wait for the database transaction to complete before updating the UI
db.afterNextTransaction { _ in
db.afterNextTransactionNested { _ in
DispatchQueue.main.async {
modalActivityIndicator.dismiss(completion: {})
}

View File

@ -43,6 +43,50 @@ public final class FullConversationCell: UITableViewCell {
return result
}()
private lazy var unreadImageView: UIView = {
let iconHeight: CGFloat = 12
let indicatorSize: CGFloat = 6
let result: UIView = UIView()
let imageView: UIImageView = UIImageView(image: UIImage(systemName: "envelope"))
imageView.contentMode = .scaleAspectFit
imageView.themeTintColor = .textPrimary
result.addSubview(imageView)
// Note: We add a 2 inset to align the bottom of the image with the bottom of the text (looks
// off otherwise)
imageView.pin(.top, to: .top, of: result, withInset: 2)
imageView.pin(.leading, to: .leading, of: result)
imageView.pin(.trailing, to: .trailing, of: result)
imageView.pin(.bottom, to: .bottom, of: result)
// Note: For some weird reason if we dont '+ 4' here the height ends up getting set to '8'
imageView.set(.height, to: (iconHeight + 4))
imageView.set(.width, to: ((imageView.image?.size.width ?? 1) / (imageView.image?.size.height ?? 1) * iconHeight))
let indicatorBackgroundView: UIView = UIView()
indicatorBackgroundView.themeBackgroundColor = .conversationButton_unreadBackground
indicatorBackgroundView.layer.cornerRadius = (indicatorSize / 2)
result.addSubview(indicatorBackgroundView)
indicatorBackgroundView.set(.width, to: indicatorSize)
indicatorBackgroundView.set(.height, to: indicatorSize)
indicatorBackgroundView.pin(.top, to: .top, of: result, withInset: 1)
indicatorBackgroundView.pin(.trailing, to: .trailing, of: result, withInset: 1)
let indicatorView: UIView = UIView()
indicatorView.themeBackgroundColor = .conversationButton_unreadBubbleBackground
indicatorView.layer.cornerRadius = ((indicatorSize - 2) / 2)
result.addSubview(indicatorView)
indicatorView.set(.width, to: (indicatorSize - 2))
indicatorView.set(.height, to: (indicatorSize - 2))
indicatorView.center(in: indicatorBackgroundView)
return result
}()
private lazy var hasMentionView: UIView = {
let result: UIView = UIView()
@ -174,7 +218,7 @@ public final class FullConversationCell: UITableViewCell {
// Label stack view
let topLabelSpacer = UIView.hStretchingSpacer()
[ displayNameLabel, isPinnedIcon, unreadCountView, hasMentionView, topLabelSpacer, timestampLabel ].forEach{ view in
[ displayNameLabel, isPinnedIcon, unreadCountView, unreadImageView, hasMentionView, topLabelSpacer, timestampLabel ].forEach{ view in
topLabelStackView.addArrangedSubview(view)
}
@ -239,6 +283,7 @@ public final class FullConversationCell: UITableViewCell {
isPinnedIcon.isHidden = true
unreadCountView.isHidden = true
unreadImageView.isHidden = true
hasMentionView.isHidden = true
timestampLabel.isHidden = false
timestampLabel.text = cellViewModel.lastInteractionDate.formattedForDisplay
@ -289,6 +334,7 @@ public final class FullConversationCell: UITableViewCell {
isPinnedIcon.isHidden = true
unreadCountView.isHidden = true
unreadImageView.isHidden = true
hasMentionView.isHidden = true
timestampLabel.isHidden = true
@ -331,7 +377,11 @@ public final class FullConversationCell: UITableViewCell {
public func update(with cellViewModel: SessionThreadViewModel) {
let unreadCount: UInt = (cellViewModel.threadUnreadCount ?? 0)
let themeBackgroundColor: ThemeValue = (unreadCount > 0 ?
let threadIsUnread: Bool = (
unreadCount > 0 ||
cellViewModel.threadWasMarkedUnread == true
)
let themeBackgroundColor: ThemeValue = (threadIsUnread ?
.conversationButton_unreadBackground :
.conversationButton_background
)
@ -349,7 +399,11 @@ public final class FullConversationCell: UITableViewCell {
isPinnedIcon.isHidden = !cellViewModel.threadIsPinned
unreadCountView.isHidden = (unreadCount <= 0)
unreadCountLabel.text = (unreadCount < 10000 ? "\(unreadCount)" : "9999+")
unreadImageView.isHidden = (!unreadCountView.isHidden || !threadIsUnread)
unreadCountLabel.text = (unreadCount <= 0 ?
"" :
(unreadCount < 10000 ? "\(unreadCount)" : "9999+")
)
unreadCountLabel.font = .boldSystemFont(
ofSize: (unreadCount < 10000 ? Values.verySmallFontSize : 8)
)
@ -412,7 +466,10 @@ public final class FullConversationCell: UITableViewCell {
}
else {
accentLineView.themeBackgroundColor = .conversationButton_unreadStripBackground
accentLineView.alpha = (!unreadCountView.isHidden ? 1 : 0.0001) // Setting the alpha to exactly 0 causes an issue on iOS 12
accentLineView.alpha = (!unreadCountView.isHidden || !unreadImageView.isHidden ?
1 :
0.0001 // Setting the alpha to exactly 0 causes an issue on iOS 12
)
}
}

View File

@ -56,10 +56,6 @@ class SessionTableViewModel<NavItemId: Equatable, Section: SessionTableSection,
preconditionFailure("abstract class - override in subclass")
}
open var pagedDataObserver: TransactionObserver? { nil }
open var footerView: AnyPublisher<UIView?, Never> { Just(nil).eraseToAnyPublisher() }
open var footerButtonInfo: AnyPublisher<SessionButton.Info?, Never> {
Just(nil).eraseToAnyPublisher()
}
func updateTableData(_ updatedData: [SectionModel]) {
self.tableData = updatedData

View File

@ -24,7 +24,9 @@ public enum SNMessagingKit { // Just to make the external API nice
[
_008_EmojiReacts.self,
_009_OpenGroupPermission.self,
_010_AddThreadIdToFTS.self,
_010_AddThreadIdToFTS.self
], // Add job priorities
[
_011_SharedUtilChanges.self
]
]

View File

@ -1077,7 +1077,7 @@ extension Attachment {
// Check the file size
SNLog("File size: \(data.count) bytes.")
if Double(data.count) > Double(FileServerAPI.maxFileSize) / FileServerAPI.fileSizeORMultiplier {
if data.count > FileServerAPI.maxFileSize {
return Fail(error: HTTPError.maxFileSizeExceeded)
.eraseToAnyPublisher()
}

View File

@ -608,13 +608,28 @@ public extension Interaction {
// MARK: - Search Queries
public extension Interaction {
static func idsForTermWithin(threadId: String, pattern: FTS5Pattern) -> SQLRequest<Int64> {
struct TimestampInfo: FetchableRecord, Codable {
public let id: Int64
public let timestampMs: Int64
public init(
id: Int64,
timestampMs: Int64
) {
self.id = id
self.timestampMs = timestampMs
}
}
static func idsForTermWithin(threadId: String, pattern: FTS5Pattern) -> SQLRequest<TimestampInfo> {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let interactionFullTextSearch: SQL = SQL(stringLiteral: Interaction.fullTextSearchTableName)
let threadIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name)
let request: SQLRequest<Int64> = """
SELECT \(interaction[.id])
let request: SQLRequest<TimestampInfo> = """
SELECT
\(interaction[.id]),
\(interaction[.timestampMs])
FROM \(Interaction.self)
JOIN \(interactionFullTextSearch) ON (
\(interactionFullTextSearch).rowid = \(interaction.alias[Column.rowID]) AND

View File

@ -25,6 +25,15 @@ public enum ConfigurationSyncJob: JobExecutor {
return
}
// On startup it's possible for multiple ConfigSyncJob's to run at the same time (which is
// redundant) so check if there is another job already running and, if so, defer this job
let jobDetails: [Int64: Data?] = JobRunner.defailsForCurrentlyRunningJobs(of: .configurationSync)
guard jobDetails.setting(job.id, nil).count == 0 else {
deferred(job) // We will re-enqueue when needed
return
}
// If we don't have a userKeyPair yet then there is no need to sync the configuration
// as the user doesn't exist yet (this will get triggered on the first launch of a
// fresh install due to the migrations getting run)

View File

@ -256,7 +256,7 @@ internal extension SessionUtil {
// If we only updated the current user contact then no need to continue
guard !targetContacts.isEmpty else { return updated }
db.afterNextTransaction { db in
db.afterNextTransactionNested { db in
do {
let atomicConf: Atomic<UnsafeMutablePointer<config_object>?> = SessionUtil.config(
for: .contacts,
@ -312,7 +312,7 @@ internal extension SessionUtil {
// Get the user public key (updating their profile is handled separately
let userPublicKey: String = getUserHexEncodedPublicKey(db)
db.afterNextTransaction { db in
db.afterNextTransactionNested { db in
do {
// Update the user profile first (if needed)
if let updatedUserProfile: Profile = updatedProfiles.first(where: { $0.id == userPublicKey }) {

View File

@ -52,14 +52,10 @@ internal extension SessionUtil {
.map { CChar($0) }
.nullTerminated()
)
let publicKey: String = String(cString: withUnsafeBytes(of: openGroup.pubkey) { [UInt8]($0) }
.map { CChar($0) }
.nullTerminated()
)
let publicKey: String = withUnsafePointer(to: openGroup.pubkey, { pubkeyBytes in
Data(bytes: pubkeyBytes, count: 32).toHexString()
})
// Note: A normal 'openGroupId' isn't lowercased but the volatile conversation
// info will always be lowercase so we force everything to lowercase to simplify
// the code
volatileThreadInfo.append(
VolatileThreadInfo(
threadId: OpenGroup.idFor(roomToken: roomToken, server: server),
@ -101,7 +97,7 @@ internal extension SessionUtil {
convo_info_volatile_iterator_advance(convoIterator)
}
convo_info_volatile_iterator_free(convoIterator) // Need to free the iterator
return volatileThreadInfo
}
@ -116,7 +112,9 @@ internal extension SessionUtil {
// which should override any synced changes (eg. 'lastReadTimestampMs')
let newerLocalChanges: [VolatileThreadInfo] = try volatileThreadInfo
.compactMap { threadInfo -> VolatileThreadInfo? in
// Fetch the "proper" threadId (we need the correct casing for updating the database)
// Note: A normal 'openGroupId' isn't lowercased but the volatile conversation
// info will always be lowercase so we need to fetch the "proper" threadId (in
// order to be able to update the corrent database entries)
guard
let threadId: String = try? SessionThread
.select(.id)
@ -234,7 +232,7 @@ internal extension SessionUtil {
guard
var cBaseUrl: [CChar] = threadInfo.cBaseUrl,
var cRoomToken: [CChar] = threadInfo.cRoomToken,
var cPubkey: [CChar] = threadInfo.cPubkey
var cPubkey: [UInt8] = threadInfo.cPubkey
else {
SNLog("Unable to create community conversation when updating last read timestamp due to missing URL info")
return
@ -293,7 +291,7 @@ internal extension SessionUtil {
)
}
db.afterNextTransaction { db in
db.afterNextTransactionNested { db in
do {
let atomicConf: Atomic<UnsafeMutablePointer<config_object>?> = SessionUtil.config(
for: .convoInfoVolatile,
@ -484,9 +482,9 @@ public extension SessionUtil {
$0.bytes.map { CChar(bitPattern: $0) }
}
}
var cPubkey: [CChar]? {
var cPubkey: [UInt8]? {
(openGroupUrlInfo?.publicKey).map {
$0.bytes.map { CChar(bitPattern: $0) }
Data(hex: $0).bytes
}
}
@ -533,31 +531,39 @@ public extension SessionUtil {
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
let timestampMsLiteral: SQL = SQL(stringLiteral: Interaction.Columns.timestampMs.name)
let request: SQLRequest<FetchedInfo> = """
SELECT
\(thread[.id]),
\(thread[.variant]),
\(thread[.markedAsUnread]),
MAX(\(interaction[.timestampMs])),
\(interaction[.timestampMs]),
\(openGroup[.server]),
\(openGroup[.roomToken]),
\(openGroup[.publicKey])
FROM \(SessionThread.self)
LEFT JOIN \(Interaction.self) ON (
\(interaction[.threadId]) = \(thread[.id]) AND
\(interaction[.wasRead]) = true AND
-- Note: Due to the complexity of how call messages are handled and the short
-- duration they exist in the swarm, we have decided to exclude trying to
-- include them when syncing the read status of conversations (they are also
-- implemented differently between platforms so including them could be a
-- significant amount of work)
\(SQL("\(interaction[.variant]) IN \(Interaction.Variant.variantsToIncrementUnreadCount.filter { $0 != .infoCall })"))
)
LEFT JOIN (
SELECT
\(interaction[.threadId]),
MAX(\(interaction[.timestampMs])) AS \(timestampMsLiteral)
FROM \(Interaction.self)
WHERE (
\(interaction[.wasRead]) = true AND
-- Note: Due to the complexity of how call messages are handled and the short
-- duration they exist in the swarm, we have decided to exclude trying to
-- include them when syncing the read status of conversations (they are also
-- implemented differently between platforms so including them could be a
-- significant amount of work)
\(SQL("\(interaction[.variant]) IN \(Interaction.Variant.variantsToIncrementUnreadCount.filter { $0 != .infoCall })"))
)
GROUP BY \(interaction[.threadId])
) AS \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])
LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id])
\(ids == nil ? SQL("") :
"WHERE \(SQL("\(thread[.id]) IN \(ids ?? [])"))"
)
GROUP BY \(thread[.id])
"""
return ((try? request.fetchAll(db)) ?? [])

View File

@ -63,7 +63,7 @@ internal extension SessionUtil {
guard
let profilePictureUrl: String = profileData.profilePictureUrl,
let profileKey: Data = profileData.profilePictureKey
else { return .none }
else { return .remove }
return .updateTo(
url: profilePictureUrl,
@ -106,26 +106,35 @@ internal extension SessionUtil {
// blocking access in it's `mutate` closure
return atomicConf.mutate { conf in
// Update the name
user_profile_set_name(conf, profile.name)
let profilePic: user_profile_pic? = profile.profilePictureUrl?
var updatedName: [CChar] = profile.name
.bytes
.map { CChar(bitPattern: $0) }
.withUnsafeBufferPointer { profileUrlPtr in
let profileKey: [UInt8]? = profile.profileEncryptionKey?.bytes
return profileKey?.withUnsafeBufferPointer { profileKeyPtr in
user_profile_set_name(conf, &updatedName)
// Either assign the updated profile pic, or sent a blank profile pic (to remove the current one)
let profilePic: user_profile_pic? = {
guard
let profilePictureUrl: String = profile.profilePictureUrl,
let profileEncryptionKey: Data = profile.profileEncryptionKey
else { return nil }
let updatedUrl: [CChar] = profilePictureUrl
.bytes
.map { CChar(bitPattern: $0) }
let updatedKey: [UInt8] = profileEncryptionKey
.bytes
return updatedUrl.withUnsafeBufferPointer { urlPtr in
updatedKey.withUnsafeBufferPointer { keyPtr in
user_profile_pic(
url: profileUrlPtr.baseAddress,
key: profileKeyPtr.baseAddress,
keylen: (profileKey?.count ?? 0)
url: urlPtr.baseAddress,
key: keyPtr.baseAddress,
keylen: updatedKey.count
)
}
}
if let profilePic: user_profile_pic = profilePic {
user_profile_set_pic(conf, profilePic)
}
}()
user_profile_set_pic(conf, (profilePic ?? user_profile_pic()))
return ConfResult(
needsPush: config_needs_push(conf),

View File

@ -51,16 +51,18 @@ public extension QueryInterfaceRequest where RowDecoder: FetchableRecord & Table
_ assignments: [ColumnAssignment]
) throws -> [RowDecoder] {
defer {
db.afterNextTransaction { db in
guard
self is QueryInterfaceRequest<Contact> ||
self is QueryInterfaceRequest<Profile> ||
self is QueryInterfaceRequest<ClosedGroup>
else { return }
// If we change one of these types then we may as well automatically enqueue
// a new config sync job once the transaction completes
ConfigurationSyncJob.enqueue(db)
// If we change one of these types then we may as well automatically enqueue
// a new config sync job once the transaction completes (but only enqueue it
// once per transaction - doing it more than once is pointless)
if
self is QueryInterfaceRequest<Contact> ||
self is QueryInterfaceRequest<Profile> ||
self is QueryInterfaceRequest<SessionThread> ||
self is QueryInterfaceRequest<ClosedGroup>
{
db.afterNextTransactionNestedOnce(dedupeIdentifier: "EnqueueConfigurationSyncJob") { db in
ConfigurationSyncJob.enqueue(db)
}
}
}

View File

@ -347,39 +347,40 @@ public enum SessionUtil {
SessionUtil.configStore.wrappedValue[key] ??
Atomic(nil)
)
var finalResult: ConfResult = mergeResult.result
// Apply the updated states to the database
switch variant {
case .userProfile:
finalResult = try SessionUtil.handleUserProfileUpdate(
db,
in: atomicConf,
mergeResult: mergeResult.result,
latestConfigUpdateSentTimestamp: mergeResult.latestSentTimestamp
)
case .contacts:
finalResult = try SessionUtil.handleContactsUpdate(
db,
in: atomicConf,
mergeResult: mergeResult.result
)
case .convoInfoVolatile:
finalResult = try SessionUtil.handleConvoInfoVolatileUpdate(
db,
in: atomicConf,
mergeResult: mergeResult.result
)
case .groups:
finalResult = try SessionUtil.handleGroupsUpdate(
db,
in: atomicConf,
mergeResult: mergeResult.result
)
}
let postHandlingResult: ConfResult = try {
switch variant {
case .userProfile:
return try SessionUtil.handleUserProfileUpdate(
db,
in: atomicConf,
mergeResult: mergeResult.result,
latestConfigUpdateSentTimestamp: mergeResult.latestSentTimestamp
)
case .contacts:
return try SessionUtil.handleContactsUpdate(
db,
in: atomicConf,
mergeResult: mergeResult.result
)
case .convoInfoVolatile:
return try SessionUtil.handleConvoInfoVolatileUpdate(
db,
in: atomicConf,
mergeResult: mergeResult.result
)
case .groups:
return try SessionUtil.handleGroupsUpdate(
db,
in: atomicConf,
mergeResult: mergeResult.result
)
}
}()
// We need to get the existing message hashes and combine them with the latest from the
// service node to ensure the next push will properly clean up old messages
@ -399,7 +400,7 @@ public enum SessionUtil {
let messageHashesChanged: Bool = (oldMessageHashes != mergeResult.messageHashes.asSet())
// Now that the changes are applied, update the cached dumps
switch (finalResult.needsDump, messageHashesChanged) {
switch (postHandlingResult.needsDump, messageHashesChanged) {
case (true, _):
// The config data had changes so regenerate the dump and save it
try atomicConf
@ -430,7 +431,7 @@ public enum SessionUtil {
default: break
}
return finalResult
return postHandlingResult
}
// Now that the local state has been updated, trigger a config sync (this will push any

View File

@ -493,7 +493,7 @@ public final class OpenGroupManager {
}
}
db.afterNextTransaction { db in
db.afterNextTransactionNested { db in
// Start the poller if needed
if dependencies.cache.pollers[server.lowercased()] == nil {
dependencies.mutableCache.mutate {

View File

@ -74,7 +74,9 @@ public enum MessageReceiver {
throw MessageReceiverError.invalidGroupPublicKey
}
guard
let encryptionKeyPairs: [ClosedGroupKeyPair] = try? closedGroup.keyPairs.order(ClosedGroupKeyPair.Columns.receivedTimestamp.desc).fetchAll(db),
let encryptionKeyPairs: [ClosedGroupKeyPair] = try? closedGroup.keyPairs
.order(ClosedGroupKeyPair.Columns.receivedTimestamp.desc)
.fetchAll(db),
!encryptionKeyPairs.isEmpty
else {
throw MessageReceiverError.noGroupKeyPair

View File

@ -30,6 +30,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat
public static let threadOnlyNotifyForMentionsKey: SQL = SQL(stringLiteral: CodingKeys.threadOnlyNotifyForMentions.stringValue)
public static let threadMessageDraftKey: SQL = SQL(stringLiteral: CodingKeys.threadMessageDraft.stringValue)
public static let threadContactIsTypingKey: SQL = SQL(stringLiteral: CodingKeys.threadContactIsTyping.stringValue)
public static let threadWasMarkedUnreadKey: SQL = SQL(stringLiteral: CodingKeys.threadWasMarkedUnread.stringValue)
public static let threadUnreadCountKey: SQL = SQL(stringLiteral: CodingKeys.threadUnreadCount.stringValue)
public static let threadUnreadMentionCountKey: SQL = SQL(stringLiteral: CodingKeys.threadUnreadMentionCount.stringValue)
public static let contactProfileKey: SQL = SQL(stringLiteral: CodingKeys.contactProfile.stringValue)
@ -60,6 +61,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat
public static let authorNameInternalKey: SQL = SQL(stringLiteral: CodingKeys.authorNameInternal.stringValue)
public static let currentUserPublicKeyKey: SQL = SQL(stringLiteral: CodingKeys.currentUserPublicKey.stringValue)
public static let threadWasMarkedUnreadString: String = CodingKeys.threadWasMarkedUnread.stringValue
public static let threadUnreadCountString: String = CodingKeys.threadUnreadCount.stringValue
public static let threadUnreadMentionCountString: String = CodingKeys.threadUnreadMentionCount.stringValue
public static let closedGroupUserCountString: String = CodingKeys.closedGroupUserCount.stringValue
@ -94,6 +96,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat
public let threadMessageDraft: String?
public let threadContactIsTyping: Bool?
public let threadWasMarkedUnread: Bool?
public let threadUnreadCount: UInt?
public let threadUnreadMentionCount: UInt?
@ -127,7 +130,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat
public let interactionId: Int64?
public let interactionVariant: Interaction.Variant?
private let interactionTimestampMs: Int64?
public let interactionTimestampMs: Int64?
public let interactionBody: String?
public let interactionState: RecipientState.State?
public let interactionHasAtLeastOneReadReceipt: Bool?
@ -228,6 +231,95 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat
)
)
}
// MARK: - Marking as Read
public enum ReadTarget {
/// Only the thread should be marked as read
case thread
/// Both the thread and interactions should be marked as read, if no interaction id is provided then all interactions for the
/// thread will be marked as read
case threadAndInteractions(interactionsBeforeInclusive: Int64?)
}
/// This method marks a thread as read and depending on the target may also update the interactions within a thread as read
public func markAsRead(target: ReadTarget) {
// Store the logic to mark a thread as read (to paths need to run this)
let threadId: String = self.threadId
let threadWasMarkedUnread: Bool? = self.threadWasMarkedUnread
let markThreadAsReadIfNeeded: () -> () = {
guard threadWasMarkedUnread == true else { return }
Storage.shared.writeAsync { db in
try SessionThread
.filter(id: threadId)
.updateAllAndConfig(
db,
SessionThread.Columns.markedAsUnread.set(to: false)
)
}
}
// Determine what we want to mark as read
switch target {
// Only mark the thread as read
case .thread: markThreadAsReadIfNeeded()
// We want to mark both the thread and interactions as read
case .threadAndInteractions(let interactionId):
guard
(self.threadUnreadCount ?? 0) > 0,
let targetInteractionId: Int64 = (interactionId ?? self.interactionId)
else {
// No unread interactions so just mark the thread as read if needed
markThreadAsReadIfNeeded()
return
}
let threadId: String = self.threadId
let threadVariant: SessionThread.Variant = self.threadVariant
let trySendReadReceipt: Bool = (self.threadIsMessageRequest == false)
Storage.shared.writeAsync { db in
// Only make this change if needed (want to avoid triggering a thread update
// if not needed)
if threadWasMarkedUnread == true {
try SessionThread
.filter(id: threadId)
.updateAllAndConfig(
db,
SessionThread.Columns.markedAsUnread.set(to: false)
)
}
try Interaction.markAsRead(
db,
interactionId: targetInteractionId,
threadId: threadId,
threadVariant: threadVariant,
includingOlder: true,
trySendReadReceipt: trySendReadReceipt
)
}
}
}
/// This method will mark a thread as read
public func markAsUnread() {
guard self.threadWasMarkedUnread != true else { return }
let threadId: String = self.threadId
Storage.shared.writeAsync { db in
try SessionThread
.filter(id: threadId)
.updateAllAndConfig(
db,
SessionThread.Columns.markedAsUnread.set(to: true)
)
}
}
}
// MARK: - Convenience Initialization
@ -261,6 +353,7 @@ public extension SessionThreadViewModel {
self.threadMessageDraft = nil
self.threadContactIsTyping = nil
self.threadWasMarkedUnread = nil
self.threadUnreadCount = unreadCount
self.threadUnreadMentionCount = nil
@ -325,6 +418,7 @@ public extension SessionThreadViewModel {
threadOnlyNotifyForMentions: self.threadOnlyNotifyForMentions,
threadMessageDraft: self.threadMessageDraft,
threadContactIsTyping: self.threadContactIsTyping,
threadWasMarkedUnread: self.threadWasMarkedUnread,
threadUnreadCount: self.threadUnreadCount,
threadUnreadMentionCount: self.threadUnreadMentionCount,
contactProfile: self.contactProfile,
@ -379,6 +473,7 @@ public extension SessionThreadViewModel {
threadOnlyNotifyForMentions: self.threadOnlyNotifyForMentions,
threadMessageDraft: self.threadMessageDraft,
threadContactIsTyping: self.threadContactIsTyping,
threadWasMarkedUnread: self.threadWasMarkedUnread,
threadUnreadCount: self.threadUnreadCount,
threadUnreadMentionCount: self.threadUnreadMentionCount,
contactProfile: self.contactProfile,
@ -464,7 +559,7 @@ public extension SessionThreadViewModel {
/// parse and might throw
///
/// Explicitly set default values for the fields ignored for search results
let numColumnsBeforeProfiles: Int = 12
let numColumnsBeforeProfiles: Int = 13
let numColumnsBetweenProfilesAndAttachmentInfo: Int = 11 // The attachment info columns will be combined
let request: SQLRequest<ViewModel> = """
@ -481,6 +576,7 @@ public extension SessionThreadViewModel {
\(thread[.onlyNotifyForMentions]) AS \(ViewModel.threadOnlyNotifyForMentionsKey),
(\(typingIndicator[.threadId]) IS NOT NULL) AS \(ViewModel.threadContactIsTypingKey),
\(thread[.markedAsUnread]) AS \(ViewModel.threadWasMarkedUnreadKey),
\(Interaction.self).\(ViewModel.threadUnreadCountKey),
\(Interaction.self).\(ViewModel.threadUnreadMentionCountKey),
@ -724,7 +820,7 @@ public extension SessionThreadViewModel {
/// parse and might throw
///
/// Explicitly set default values for the fields ignored for search results
let numColumnsBeforeProfiles: Int = 14
let numColumnsBeforeProfiles: Int = 15
let request: SQLRequest<ViewModel> = """
SELECT
\(thread.alias[Column.rowID]) AS \(ViewModel.rowIdKey),
@ -750,6 +846,7 @@ public extension SessionThreadViewModel {
\(thread[.onlyNotifyForMentions]) AS \(ViewModel.threadOnlyNotifyForMentionsKey),
\(thread[.messageDraft]) AS \(ViewModel.threadMessageDraftKey),
\(thread[.markedAsUnread]) AS \(ViewModel.threadWasMarkedUnreadKey),
\(Interaction.self).\(ViewModel.threadUnreadCountKey),
\(ViewModel.contactProfileKey).*,

View File

@ -487,6 +487,7 @@ public struct ProfileManager {
// Update the cached avatar image value
profileAvatarCache.mutate { $0[fileName] = data }
UserDefaults.standard[.lastProfilePictureUpload] = Date()
SNLog("Successfully uploaded avatar image.")
success((downloadUrl, fileName, newProfileKey))
@ -553,11 +554,7 @@ public struct ProfileManager {
profileChanges.append(Profile.Columns.profilePictureUrl.set(to: nil))
profileChanges.append(Profile.Columns.profileEncryptionKey.set(to: nil))
// Profile filename (this isn't synchronized between devices so can be immediately saved)
_ = try? Profile
.filter(id: publicKey)
.updateAll(db, Profile.Columns.profilePictureFileName.set(to: nil))
profileChanges.append(Profile.Columns.profilePictureFileName.set(to: nil))
case .updateTo(let url, let key, let fileName):
if
@ -573,11 +570,9 @@ public struct ProfileManager {
avatarNeedsDownload = true
}
// Profile filename (this isn't synchronized between devices so can be immediately saved)
// Profile filename (this isn't synchronized between devices)
if let fileName: String = fileName {
_ = try? Profile
.filter(id: publicKey)
.updateAll(db, Profile.Columns.profilePictureFileName.set(to: fileName))
profileChanges.append(Profile.Columns.profilePictureFileName.set(to: fileName))
}
}
}
@ -623,7 +618,7 @@ public struct ProfileManager {
// Download the profile picture if needed
guard avatarNeedsDownload else { return }
db.afterNextTransaction { db in
db.afterNextTransactionNested { db in
// Need to refetch to ensure the db changes have occurred
ProfileManager.downloadAvatar(for: Profile.fetchOrCreate(db, id: publicKey))
}

View File

@ -65,6 +65,7 @@ class ConfigContactsSpec: QuickSpec {
expect(toPush).toNot(beNil())
expect(seqno).to(equal(0))
expect(toPushLen).to(equal(256))
toPush?.deallocate()
// Update the contact data
let contact2Name: [CChar] = "Joe"
@ -125,7 +126,7 @@ class ConfigContactsSpec: QuickSpec {
let error2: UnsafeMutablePointer<CChar>? = nil
var conf2: UnsafeMutablePointer<config_object>? = nil
expect(contacts_init(&conf2, &edSK, dump1, dump1Len, error2)).to(equal(0))
error?.deallocate()
error2?.deallocate()
dump1?.deallocate()
expect(config_needs_push(conf2)).to(beFalse())

View File

@ -0,0 +1,314 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import Sodium
import SessionUtil
import SessionUtilitiesKit
import Quick
import Nimble
/// This spec is designed to replicate the initial test cases for the libSession-util to ensure the behaviour matches
class ConfigConvoInfoVolatileSpec: QuickSpec {
// MARK: - Spec
override func spec() {
it("generates ConvoInfoVolatileS configs correctly") {
let seed: Data = Data(hex: "0123456789abcdef0123456789abcdef")
// FIXME: Would be good to move these into the libSession-util instead of using Sodium separately
let identity = try! Identity.generate(from: seed)
var edSK: [UInt8] = identity.ed25519KeyPair.secretKey
expect(edSK.toHexString().suffix(64))
.to(equal("4cb76fdc6d32278e3f83dbf608360ecc6b65727934b85d2fb86862ff98c46ab7"))
expect(identity.x25519KeyPair.publicKey.toHexString())
.to(equal("d2ad010eeb72d72e561d9de7bd7b6989af77dcabffa03a5111a6c859ae5c3a72"))
expect(String(edSK.toHexString().prefix(32))).to(equal(seed.toHexString()))
// Initialize a brand new, empty config because we have no dump data to deal with.
let error: UnsafeMutablePointer<CChar>? = nil
var conf: UnsafeMutablePointer<config_object>? = nil
expect(convo_info_volatile_init(&conf, &edSK, nil, 0, error)).to(equal(0))
error?.deallocate()
// Empty contacts shouldn't have an existing contact
var definitelyRealId: [CChar] = "055000000000000000000000000000000000000000000000000000000000000000"
.bytes
.map { CChar(bitPattern: $0) }
var oneToOne1: convo_info_volatile_1to1 = convo_info_volatile_1to1()
expect(convo_info_volatile_get_1to1(conf, &oneToOne1, &definitelyRealId)).to(beFalse())
expect(convo_info_volatile_size(conf)).to(equal(0))
var oneToOne2: convo_info_volatile_1to1 = convo_info_volatile_1to1()
expect(convo_info_volatile_get_or_construct_1to1(conf, &oneToOne2, definitelyRealId))
.to(beTrue())
let oneToOne2SessionId: [CChar] = withUnsafeBytes(of: oneToOne2.session_id) { [UInt8]($0) }
.map { CChar($0) }
expect(oneToOne2SessionId).to(equal(definitelyRealId.nullTerminated()))
expect(oneToOne2.last_read).to(equal(0))
expect(oneToOne2.unread).to(beFalse())
// No need to sync a conversation with a default state
expect(config_needs_push(conf)).to(beFalse())
expect(config_needs_dump(conf)).to(beFalse())
// Update the last read
let nowTimestampMs: Int64 = Int64(floor(Date().timeIntervalSince1970 * 1000))
oneToOne2.last_read = nowTimestampMs
// The new data doesn't get stored until we call this:
convo_info_volatile_set_1to1(conf, &oneToOne2)
var legacyClosed1: convo_info_volatile_legacy_closed = convo_info_volatile_legacy_closed()
var oneToOne3: convo_info_volatile_1to1 = convo_info_volatile_1to1()
expect(convo_info_volatile_get_legacy_closed(conf, &legacyClosed1, &definitelyRealId))
.to(beFalse())
expect(convo_info_volatile_get_1to1(conf, &oneToOne3, &definitelyRealId)).to(beTrue())
expect(oneToOne3.last_read).to(equal(nowTimestampMs))
expect(config_needs_push(conf)).to(beTrue())
expect(config_needs_dump(conf)).to(beTrue())
var openGroupBaseUrl: [CChar] = "http://Example.ORG:5678"
.bytes
.map { CChar(bitPattern: $0) }
let openGroupBaseUrlResult: [CChar] = ("http://Example.ORG:5678"
.lowercased()
.bytes
.map { CChar(bitPattern: $0) } +
[CChar](repeating: 0, count: (268 - openGroupBaseUrl.count))
)
var openGroupRoom: [CChar] = "SudokuRoom"
.bytes
.map { CChar(bitPattern: $0) }
let openGroupRoomResult: [CChar] = ("SudokuRoom"
.lowercased()
.bytes
.map { CChar(bitPattern: $0) } +
[CChar](repeating: 0, count: (65 - openGroupRoom.count))
)
var openGroupPubkey: [UInt8] = Data(hex: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
.bytes
var openGroup1: convo_info_volatile_open = convo_info_volatile_open()
expect(convo_info_volatile_get_or_construct_open(conf, &openGroup1, &openGroupBaseUrl, &openGroupRoom, &openGroupPubkey)).to(beTrue())
expect(withUnsafeBytes(of: openGroup1.base_url) { [UInt8]($0) }
.map { CChar($0) }
).to(equal(openGroupBaseUrlResult))
expect(withUnsafeBytes(of: openGroup1.room) { [UInt8]($0) }
.map { CChar($0) }
).to(equal(openGroupRoomResult))
expect(withUnsafePointer(to: openGroup1.pubkey) { Data(bytes: $0, count: 32).toHexString() })
.to(equal("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"))
openGroup1.unread = true
// The new data doesn't get stored until we call this:
convo_info_volatile_set_open(conf, &openGroup1);
var toPush: UnsafeMutablePointer<UInt8>? = nil
var toPushLen: Int = 0
// We don't need to push since we haven't changed anything, so this call is mainly just for
// testing:
let seqno: Int64 = config_push(conf, &toPush, &toPushLen)
expect(toPush).toNot(beNil())
expect(seqno).to(equal(1))
expect(toPushLen).to(equal(512))
toPush?.deallocate()
// Pretend we uploaded it
config_confirm_pushed(conf, seqno)
expect(config_needs_dump(conf)).to(beTrue())
expect(config_needs_push(conf)).to(beFalse())
var dump1: UnsafeMutablePointer<UInt8>? = nil
var dump1Len: Int = 0
config_dump(conf, &dump1, &dump1Len)
let error2: UnsafeMutablePointer<CChar>? = nil
var conf2: UnsafeMutablePointer<config_object>? = nil
expect(convo_info_volatile_init(&conf2, &edSK, dump1, dump1Len, error2)).to(equal(0))
error2?.deallocate()
dump1?.deallocate()
expect(config_needs_dump(conf2)).to(beFalse())
expect(config_needs_push(conf2)).to(beFalse())
var oneToOne4: convo_info_volatile_1to1 = convo_info_volatile_1to1()
expect(convo_info_volatile_get_1to1(conf2, &oneToOne4, &definitelyRealId)).to(equal(true))
expect(oneToOne4.last_read).to(equal(nowTimestampMs))
expect(
withUnsafeBytes(of: oneToOne4.session_id) { [UInt8]($0) }
.map { CChar($0) }
.nullTerminated()
).to(equal(definitelyRealId.nullTerminated()))
expect(oneToOne4.unread).to(beFalse())
var openGroup2: convo_info_volatile_open = convo_info_volatile_open()
expect(convo_info_volatile_get_open(conf2, &openGroup2, &openGroupBaseUrl, &openGroupRoom, &openGroupPubkey)).to(beTrue())
expect(withUnsafeBytes(of: openGroup2.base_url) { [UInt8]($0) }
.map { CChar($0) }
).to(equal(openGroupBaseUrlResult))
expect(withUnsafeBytes(of: openGroup2.room) { [UInt8]($0) }
.map { CChar($0) }
).to(equal(openGroupRoomResult))
expect(withUnsafePointer(to: openGroup2.pubkey) { Data(bytes: $0, count: 32).toHexString() })
.to(equal("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"))
openGroup2.unread = true
var anotherId: [CChar] = "051111111111111111111111111111111111111111111111111111111111111111"
.bytes
.map { CChar(bitPattern: $0) }
var oneToOne5: convo_info_volatile_1to1 = convo_info_volatile_1to1()
expect(convo_info_volatile_get_or_construct_1to1(conf2, &oneToOne5, &anotherId)).to(beTrue())
convo_info_volatile_set_1to1(conf2, &oneToOne5)
var thirdId: [CChar] = "05cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
.bytes
.map { CChar(bitPattern: $0) }
var legacyClosed2: convo_info_volatile_legacy_closed = convo_info_volatile_legacy_closed()
expect(convo_info_volatile_get_or_construct_legacy_closed(conf2, &legacyClosed2, &thirdId)).to(beTrue())
legacyClosed2.last_read = (nowTimestampMs - 50)
convo_info_volatile_set_legacy_closed(conf2, &legacyClosed2)
expect(config_needs_push(conf2)).to(beTrue())
var toPush2: UnsafeMutablePointer<UInt8>? = nil
var toPush2Len: Int = 0
let seqno2: Int64 = config_push(conf2, &toPush2, &toPush2Len)
expect(seqno2).to(equal(2))
// Check the merging
var mergeData: [UnsafePointer<UInt8>?] = [UnsafePointer(toPush2)]
var mergeSize: [Int] = [toPush2Len]
expect(config_merge(conf, &mergeData, &mergeSize, 1)).to(equal(1))
config_confirm_pushed(conf, seqno)
toPush2?.deallocate()
expect(config_needs_push(conf)).to(beFalse())
for targetConf in [conf, conf2] {
// Iterate through and make sure we got everything we expected
var seen: [String] = []
expect(convo_info_volatile_size(conf)).to(equal(4))
expect(convo_info_volatile_size_1to1(conf)).to(equal(2))
expect(convo_info_volatile_size_open(conf)).to(equal(1))
expect(convo_info_volatile_size_legacy_closed(conf)).to(equal(1))
var c1: convo_info_volatile_1to1 = convo_info_volatile_1to1()
var c2: convo_info_volatile_open = convo_info_volatile_open()
var c3: convo_info_volatile_legacy_closed = convo_info_volatile_legacy_closed()
let it: OpaquePointer = convo_info_volatile_iterator_new(targetConf)
while !convo_info_volatile_iterator_done(it) {
if convo_info_volatile_it_is_1to1(it, &c1) {
let sessionId: String = String(cString: withUnsafeBytes(of: c1.session_id) { [UInt8]($0) }
.map { CChar($0) }
.nullTerminated()
)
seen.append("1-to-1: \(sessionId)")
}
else if convo_info_volatile_it_is_open(it, &c2) {
let baseUrl: String = String(cString: withUnsafeBytes(of: c2.base_url) { [UInt8]($0) }
.map { CChar($0) }
.nullTerminated()
)
let room: String = String(cString: withUnsafeBytes(of: c2.room) { [UInt8]($0) }
.map { CChar($0) }
.nullTerminated()
)
seen.append("og: \(baseUrl)/r/\(room)")
}
else if convo_info_volatile_it_is_legacy_closed(it, &c3) {
let groupId: String = String(cString: withUnsafeBytes(of: c3.group_id) { [UInt8]($0) }
.map { CChar($0) }
.nullTerminated()
)
seen.append("cl: \(groupId)")
}
convo_info_volatile_iterator_advance(it)
}
convo_info_volatile_iterator_free(it)
expect(seen).to(equal([
"1-to-1: 051111111111111111111111111111111111111111111111111111111111111111",
"1-to-1: 055000000000000000000000000000000000000000000000000000000000000000",
"og: http://example.org:5678/r/sudokuroom",
"cl: 05cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
]))
}
var fourthId: [CChar] = "052000000000000000000000000000000000000000000000000000000000000000"
.bytes
.map { CChar(bitPattern: $0) }
expect(config_needs_push(conf)).to(beFalse())
convo_info_volatile_erase_1to1(conf, &fourthId)
expect(config_needs_push(conf)).to(beFalse())
convo_info_volatile_erase_1to1(conf, &definitelyRealId)
expect(config_needs_push(conf)).to(beTrue())
expect(convo_info_volatile_size(conf)).to(equal(3))
expect(convo_info_volatile_size_1to1(conf)).to(equal(1))
// Check the single-type iterators:
var seen1: [String] = []
var c1: convo_info_volatile_1to1 = convo_info_volatile_1to1()
let it1: OpaquePointer = convo_info_volatile_iterator_new_1to1(conf)
while !convo_info_volatile_iterator_done(it1) {
expect(convo_info_volatile_it_is_1to1(it1, &c1)).to(beTrue())
let sessionId: String = String(cString: withUnsafeBytes(of: c1.session_id) { [UInt8]($0) }
.map { CChar($0) }
.nullTerminated()
)
seen1.append(sessionId)
convo_info_volatile_iterator_advance(it1)
}
convo_info_volatile_iterator_free(it1)
expect(seen1).to(equal([
"051111111111111111111111111111111111111111111111111111111111111111"
]))
var seen2: [String] = []
var c2: convo_info_volatile_open = convo_info_volatile_open()
let it2: OpaquePointer = convo_info_volatile_iterator_new_open(conf)
while !convo_info_volatile_iterator_done(it2) {
expect(convo_info_volatile_it_is_open(it2, &c2)).to(beTrue())
let baseUrl: String = String(cString: withUnsafeBytes(of: c2.base_url) { [UInt8]($0) }
.map { CChar($0) }
.nullTerminated()
)
seen2.append(baseUrl)
convo_info_volatile_iterator_advance(it2)
}
convo_info_volatile_iterator_free(it2)
expect(seen2).to(equal([
"http://example.org:5678"
]))
var seen3: [String] = []
var c3: convo_info_volatile_legacy_closed = convo_info_volatile_legacy_closed()
let it3: OpaquePointer = convo_info_volatile_iterator_new_legacy_closed(conf)
while !convo_info_volatile_iterator_done(it3) {
expect(convo_info_volatile_it_is_legacy_closed(it3, &c3)).to(beTrue())
let groupId: String = String(cString: withUnsafeBytes(of: c3.group_id) { [UInt8]($0) }
.map { CChar($0) }
.nullTerminated()
)
seen3.append(groupId)
convo_info_volatile_iterator_advance(it3)
}
convo_info_volatile_iterator_free(it3)
expect(seen3).to(equal([
"05cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
]))
}
}
}

View File

@ -3665,11 +3665,11 @@ class OpenGroupManagerSpec: QuickSpec {
it("adds the image retrieval promise to the cache") {
class TestNeverReturningApi: OnionRequestAPIType {
static func sendOnionRequest(_ request: URLRequest, to server: String, with x25519PublicKey: String) -> AnyPublisher<(ResponseInfoType, Data?), Error> {
static func sendOnionRequest(_ request: URLRequest, to server: String, with x25519PublicKey: String, timeout: TimeInterval) -> AnyPublisher<(ResponseInfoType, Data?), Error> {
return Future<(ResponseInfoType, Data?), Error> { _ in }.eraseToAnyPublisher()
}
static func sendOnionRequest(_ payload: Data, to snode: Snode) -> AnyPublisher<(ResponseInfoType, Data?), Error> {
static func sendOnionRequest(_ payload: Data, to snode: Snode, timeout: TimeInterval) -> AnyPublisher<(ResponseInfoType, Data?), Error> {
return Just(Data())
.setFailureType(to: Error.self)
.map { data in (HTTP.ResponseInfo(code: 0, headers: [:]), data) }

View File

@ -38,7 +38,7 @@ class TestOnionRequestAPI: OnionRequestAPIType {
class var mockResponse: Data? { return nil }
static func sendOnionRequest(_ request: URLRequest, to server: String, with x25519PublicKey: String) -> AnyPublisher<(ResponseInfoType, Data?), Error> {
static func sendOnionRequest(_ request: URLRequest, to server: String, with x25519PublicKey: String, timeout: TimeInterval) -> AnyPublisher<(ResponseInfoType, Data?), Error> {
let responseInfo: ResponseInfo = ResponseInfo(
requestData: RequestData(
urlString: request.url?.absoluteString,
@ -62,7 +62,7 @@ class TestOnionRequestAPI: OnionRequestAPIType {
.eraseToAnyPublisher()
}
static func sendOnionRequest(_ payload: Data, to snode: Snode) -> AnyPublisher<(ResponseInfoType, Data?), Error> {
static func sendOnionRequest(_ payload: Data, to snode: Snode, timeout: TimeInterval) -> AnyPublisher<(ResponseInfoType, Data?), Error> {
let responseInfo: ResponseInfo = ResponseInfo(
requestData: RequestData(
urlString: "\(snode.address):\(snode.port)/onion_req/v2",

View File

@ -17,7 +17,8 @@ public enum SNSnodeKit { // Just to make the external API nice
],
[
_004_FlagMessageHashAsDeletedOrInvalid.self
]
],
[] // Add job priorities
]
)
}

View File

@ -89,8 +89,10 @@ internal extension Snode {
return try SnodeSet
.filter(SnodeSet.Columns.key.like("\(SnodeSet.onionRequestPathPrefix)%"))
.order(SnodeSet.Columns.nodeIndex)
.order(SnodeSet.Columns.key)
.order(
SnodeSet.Columns.nodeIndex,
SnodeSet.Columns.key
)
.including(required: SnodeSet.node)
.asRequest(of: ResultWrapper.self)
.fetchAll(db)

View File

@ -15,7 +15,7 @@ public enum SNUIKit {
[], // YDB Removal
[
_001_ThemePreferences.self
]
] // Add job priorities
]
)
}

View File

@ -86,6 +86,7 @@ internal enum Theme_ClassicDark: ThemeColors {
.conversationButton_swipeDestructive: .dangerDark,
.conversationButton_swipeSecondary: .classicDark2,
.conversationButton_swipeTertiary: Theme.PrimaryColor.orange.color,
.conversationButton_swipeRead: .classicDark3,
// InputButton
.inputButton_background: .classicDark2,

View File

@ -86,6 +86,7 @@ internal enum Theme_ClassicLight: ThemeColors {
.conversationButton_swipeDestructive: .dangerLight,
.conversationButton_swipeSecondary: .classicLight1,
.conversationButton_swipeTertiary: Theme.PrimaryColor.orange.color,
.conversationButton_swipeRead: .classicLight3,
// InputButton
.inputButton_background: .classicLight4,

View File

@ -86,6 +86,7 @@ internal enum Theme_OceanDark: ThemeColors {
.conversationButton_swipeDestructive: .dangerDark,
.conversationButton_swipeSecondary: .oceanDark2,
.conversationButton_swipeTertiary: Theme.PrimaryColor.orange.color,
.conversationButton_swipeRead: .primary,
// InputButton
.inputButton_background: .oceanDark4,

View File

@ -86,6 +86,7 @@ internal enum Theme_OceanLight: ThemeColors {
.conversationButton_swipeDestructive: .dangerLight,
.conversationButton_swipeSecondary: .oceanLight2,
.conversationButton_swipeTertiary: Theme.PrimaryColor.orange.color,
.conversationButton_swipeRead: .primary,
// InputButton
.inputButton_background: .oceanLight5,

View File

@ -174,6 +174,7 @@ public indirect enum ThemeValue: Hashable {
case conversationButton_swipeDestructive
case conversationButton_swipeSecondary
case conversationButton_swipeTertiary
case conversationButton_swipeRead
// InputButton
case inputButton_background

View File

@ -447,7 +447,16 @@ public extension UIToolbar {
public extension UIContextualAction {
var themeBackgroundColor: ThemeValue? {
set { ThemeManager.set(self, keyPath: \.backgroundColor, to: newValue) }
set {
guard let newValue: ThemeValue = newValue else {
self.backgroundColor = nil
return
}
self.backgroundColor = UIColor(dynamicProvider: { _ in
(ThemeManager.currentTheme.color(for: newValue) ?? .clear)
})
}
get { return nil }
}
}

View File

@ -0,0 +1,176 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import SessionUtilitiesKit
public extension UIContextualAction {
private static var lookupMap: Atomic<[Int: [String: [Int: ThemeValue]]]> = Atomic([:])
enum Side: Int {
case leading
case trailing
func key(for indexPath: IndexPath) -> String {
return "\(indexPath.section)-\(indexPath.row)-\(rawValue)"
}
init?(for view: UIView) {
guard view.frame.minX == 0 else {
self = .trailing
return
}
self = .leading
}
}
convenience init(
title: String? = nil,
icon: UIImage? = nil,
iconHeight: CGFloat = Values.mediumFontSize,
themeTintColor: ThemeValue = .textPrimary,
themeBackgroundColor: ThemeValue,
side: Side,
actionIndex: Int,
indexPath: IndexPath,
tableView: UITableView,
handler: @escaping UIContextualAction.Handler
) {
self.init(style: .normal, title: title, handler: handler)
self.image = UIContextualAction
.imageWith(
title: title,
icon: icon,
iconHeight: iconHeight,
themeTintColor: themeTintColor
)?
.withRenderingMode(.alwaysTemplate)
self.themeBackgroundColor = themeBackgroundColor
UIContextualAction.lookupMap.mutate {
$0[tableView.hashValue] = ($0[tableView.hashValue] ?? [:])
.setting(
side.key(for: indexPath),
(($0[tableView.hashValue] ?? [:])[side.key(for: indexPath)] ?? [:])
.setting(actionIndex, themeTintColor)
)
}
}
private static func imageWith(
title: String?,
icon: UIImage?,
iconHeight: CGFloat,
themeTintColor: ThemeValue
) -> UIImage? {
let stackView: UIStackView = UIStackView()
stackView.axis = .vertical
stackView.alignment = .center
stackView.spacing = 3
if let icon: UIImage = icon {
let aspectRatio: CGFloat = (icon.size.width / icon.size.height)
let imageView: UIImageView = UIImageView(image: icon)
imageView.frame = CGRect(x: 0, y: 0, width: (iconHeight * aspectRatio), height: iconHeight)
imageView.contentMode = .scaleAspectFit
imageView.themeTintColor = themeTintColor
stackView.addArrangedSubview(imageView)
}
if let title: String = title {
let label: UILabel = UILabel()
label.font = .systemFont(ofSize: Values.smallFontSize)
label.text = title
label.textAlignment = .center
label.themeTextColor = themeTintColor
label.minimumScaleFactor = 0.75
label.numberOfLines = (title.components(separatedBy: " ").count > 1 ? 2 : 1)
label.frame = CGRect(
origin: .zero,
size: label.sizeThatFits(CGSize(width: 68, height: 999))
)
label.set(.width, to: label.frame.width)
stackView.addArrangedSubview(label)
}
stackView.frame = CGRect(
origin: .zero,
size: stackView.systemLayoutSizeFitting(CGSize(width: 999, height: 999))
)
// Based on https://stackoverflow.com/a/41288197/1118398
let renderFormat: UIGraphicsImageRendererFormat = UIGraphicsImageRendererFormat()
renderFormat.scale = UIScreen.main.scale
let renderer: UIGraphicsImageRenderer = UIGraphicsImageRenderer(
size: stackView.bounds.size,
format: renderFormat
)
return renderer.image { rendererContext in
stackView.layer.render(in: rendererContext.cgContext)
}
}
private static func firstSubviewOfType<T>(in superview: UIView) -> T? {
guard !(superview is T) else { return superview as? T }
guard !superview.subviews.isEmpty else { return nil }
for subview in superview.subviews {
if let result: T = firstSubviewOfType(in: subview) {
return result
}
}
return nil
}
static func willBeginEditing(indexPath: IndexPath, tableView: UITableView) {
guard
let targetCell: UITableViewCell = tableView.cellForRow(at: indexPath),
targetCell.superview != tableView,
let targetSuperview: UIView = targetCell.superview?
.subviews
.filter({ $0 != targetCell })
.first,
let side: Side = Side(for: targetSuperview),
let themeMap: [Int: ThemeValue] = UIContextualAction.lookupMap.wrappedValue
.getting(tableView.hashValue)?
.getting(side.key(for: indexPath)),
targetSuperview.subviews.count == themeMap.count
else { return }
let targetViews: [UIImageView] = targetSuperview.subviews
.compactMap { subview in firstSubviewOfType(in: subview) }
guard targetViews.count == themeMap.count else { return }
// Set the imageView and background colours (so they change correctly when the theme changes)
targetViews.enumerated().forEach { index, targetView in
guard let themeTintColor: ThemeValue = themeMap[index] else { return }
targetView.themeTintColor = themeTintColor
}
}
static func didEndEditing(indexPath: IndexPath?, tableView: UITableView) {
guard let indexPath: IndexPath = indexPath else { return }
let leadingKey: String = Side.leading.key(for: indexPath)
let trailingKey: String = Side.trailing.key(for: indexPath)
guard
UIContextualAction.lookupMap.wrappedValue[tableView.hashValue]?[leadingKey] != nil ||
UIContextualAction.lookupMap.wrappedValue[tableView.hashValue]?[trailingKey] != nil
else { return }
UIContextualAction.lookupMap.mutate {
$0[tableView.hashValue]?[leadingKey] = nil
$0[tableView.hashValue]?[trailingKey] = nil
if $0[tableView.hashValue]?.isEmpty == true {
$0[tableView.hashValue] = nil
}
}
}
}

View File

@ -17,7 +17,9 @@ public enum SNUtilitiesKit { // Just to make the external API nice
],
[], // Other DB migrations
[], // Legacy DB removal
[]
[
_004_AddJobPriority.self
]
]
)
}

View File

@ -0,0 +1,42 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import YapDatabase
enum _004_AddJobPriority: Migration {
static let target: TargetMigrations.Identifier = .utilitiesKit
static let identifier: String = "AddJobPriority"
static let needsConfigSync: Bool = false
static let minExpectedRunDuration: TimeInterval = 0.1
static func migrate(_ db: Database) throws {
// Add `priority` to the job table
try db.alter(table: Job.self) { t in
t.add(.priority, .integer).defaults(to: 0)
}
// Update the priorities for the below job types (want to ensure they run in the order
// specified to avoid weird bugs)
let variantPriorities: [Int: [Job.Variant]] = [
7: [Job.Variant.disappearingMessages],
6: [Job.Variant.failedMessageSends, Job.Variant.failedAttachmentDownloads],
5: [Job.Variant.getSnodePool],
4: [Job.Variant.syncPushTokens],
3: [Job.Variant.retrieveDefaultOpenGroupRooms],
2: [Job.Variant.updateProfilePicture],
1: [Job.Variant.garbageCollection]
]
try variantPriorities.forEach { priority, variants in
try Job
.filter(variants.contains(Job.Columns.variant))
.updateAll(
db,
Job.Columns.priority.set(to: priority)
)
}
Storage.update(progress: 1, for: self, in: target) // In case this is the last migration
}
}

View File

@ -28,6 +28,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
public typealias Columns = CodingKeys
public enum CodingKeys: String, CodingKey, ColumnExpression {
case id
case priority
case failureCount
case variant
case behaviour
@ -132,6 +133,15 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
/// the database yet this value will be `nil`
public var id: Int64? = nil
/// The `priority` value is used to allow for forcing some jobs to run before others (Default value `0`)
///
/// Jobs will be run in the following order:
/// - Jobs scheduled in the past (or with no `nextRunTimestamp`) first
/// - Jobs with a higher `priority` value
/// - Jobs with a sooner `nextRunTimestamp` value
/// - The order the job was inserted into the database
public var priority: Int64
/// A counter for the number of times this job has failed
public let failureCount: UInt
@ -190,6 +200,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
fileprivate init(
id: Int64?,
priority: Int64 = 0,
failureCount: UInt,
variant: Variant,
behaviour: Behaviour,
@ -207,6 +218,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
)
self.id = id
self.priority = priority
self.failureCount = failureCount
self.variant = variant
self.behaviour = behaviour
@ -219,6 +231,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
}
public init(
priority: Int64 = 0,
failureCount: UInt = 0,
variant: Variant,
behaviour: Behaviour = .runOnce,
@ -234,6 +247,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
shouldSkipLaunchBecomeActive: shouldSkipLaunchBecomeActive
)
self.priority = priority
self.failureCount = failureCount
self.variant = variant
self.behaviour = behaviour
@ -246,6 +260,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
}
public init?<T: Encodable>(
priority: Int64 = 0,
failureCount: UInt = 0,
variant: Variant,
behaviour: Behaviour = .runOnce,
@ -268,6 +283,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
let detailsData: Data = try? JSONEncoder().encode(details)
else { return nil }
self.priority = priority
self.failureCount = failureCount
self.variant = variant
self.behaviour = behaviour
@ -328,8 +344,12 @@ extension Job {
)
)
.filter(variants.contains(Job.Columns.variant))
.order(Job.Columns.nextRunTimestamp)
.order(Job.Columns.id)
.order(
Job.Columns.nextRunTimestamp > Date().timeIntervalSince1970, // Past jobs first
Job.Columns.priority.desc,
Job.Columns.nextRunTimestamp,
Job.Columns.id
)
if excludeFutureJobs {
query = query.filter(Job.Columns.nextRunTimestamp <= Date().timeIntervalSince1970)
@ -352,6 +372,7 @@ public extension Job {
) -> Job {
return Job(
id: self.id,
priority: self.priority,
failureCount: failureCount,
variant: self.variant,
behaviour: self.behaviour,
@ -369,6 +390,7 @@ public extension Job {
return Job(
id: self.id,
priority: self.priority,
failureCount: self.failureCount,
variant: self.variant,
behaviour: self.behaviour,

View File

@ -1013,18 +1013,20 @@ public enum PagedData {
target: updatedData
)
// No need to do anything if there were no changes
guard !changeset.isEmpty else { return }
// If we have the callback then trigger it, otherwise just store the changes to be sent
// to the callback if we ever start observing again (when we have the callback it needs
// to do the data updating as it's tied to UI updates and can cause crashes if not updated
// in the correct order)
/// If we have the callback then trigger it, otherwise just store the changes to be sent to the callback if we ever
/// start observing again (when we have the callback it needs to do the data updating as it's tied to UI updates
/// and can cause crashes if not updated in the correct order)
///
/// **Note:** We do this even if the 'changeset' is empty because if this change reverts a previous change we
/// need to ensure the `onUnobservedDataChange` gets cleared so it doesn't end up in an invalid state
guard let onDataChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ()) = onDataChange else {
onUnobservedDataChange(updatedData, changeset)
return
}
// No need to do anything if there were no changes
guard !changeset.isEmpty else { return }
onDataChange(updatedData, changeset)
}

View File

@ -36,4 +36,93 @@ public extension Database {
sqlite3_interrupt(sqliteConnection)
}
/// This is a custom implementation of the `afterNextTransaction` method which executes the closures within their own
/// transactions to allow for nesting of 'afterNextTransaction' actions
///
/// **Note:** GRDB doesn't notify read-only transactions to transaction observers
func afterNextTransactionNested(
onCommit: @escaping (Database) -> Void,
onRollback: @escaping (Database) -> Void = { _ in }
) {
afterNextTransactionNestedOnce(
dedupeIdentifier: UUID().uuidString,
onCommit: onCommit,
onRollback: onRollback
)
}
func afterNextTransactionNestedOnce(
dedupeIdentifier: String,
onCommit: @escaping (Database) -> Void,
onRollback: @escaping (Database) -> Void = { _ in }
) {
// Only allow a single observer per `dedupeIdentifier` per transaction, this allows us to
// schedule an action to run at most once per transaction (eg. auto-scheduling a ConfigSyncJob
// when receiving messages)
guard !TransactionHandler.registeredHandlers.wrappedValue.contains(dedupeIdentifier) else {
return
}
add(
transactionObserver: TransactionHandler(
identifier: dedupeIdentifier,
onCommit: onCommit,
onRollback: onRollback
),
extent: .nextTransaction
)
}
}
fileprivate class TransactionHandler: TransactionObserver {
static var registeredHandlers: Atomic<Set<String>> = Atomic([])
let identifier: String
let onCommit: (Database) -> Void
let onRollback: (Database) -> Void
init(
identifier: String,
onCommit: @escaping (Database) -> Void,
onRollback: @escaping (Database) -> Void
) {
self.identifier = identifier
self.onCommit = onCommit
self.onRollback = onRollback
TransactionHandler.registeredHandlers.mutate { $0.insert(identifier) }
}
// Ignore changes
func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool { false }
func databaseDidChange(with event: DatabaseEvent) { }
func databaseDidCommit(_ db: Database) {
TransactionHandler.registeredHandlers.mutate { $0.remove(identifier) }
do {
try db.inTransaction {
onCommit(db)
return .commit
}
}
catch {
SNLog("[Database] afterNextTransactionNested onCommit failed")
}
}
func databaseDidRollback(_ db: Database) {
TransactionHandler.registeredHandlers.mutate { $0.remove(identifier) }
do {
try db.inTransaction {
onRollback(db)
return .commit
}
}
catch {
SNLog("[Database] afterNextTransactionNested onRollback failed")
}
}
}

View File

@ -34,6 +34,12 @@ public extension Dictionary {
return self[key]
}
func getting(_ key: Key?) -> Value? {
guard let key: Key = key else { return nil }
return self[key]
}
func setting(_ key: Key?, _ value: Value?) -> [Key: Value] {
guard let key: Key = key else { return self }

View File

@ -143,7 +143,7 @@ public final class JobRunner {
guard canStartJob else { return }
// Start the job runner if needed
db.afterNextTransaction { _ in
db.afterNextTransactionNested { _ in
queues.wrappedValue[updatedJob.variant]?.start()
}
}
@ -166,7 +166,7 @@ public final class JobRunner {
guard canStartJob else { return }
// Start the job runner if needed
db.afterNextTransaction { _ in
db.afterNextTransactionNested { _ in
queues.wrappedValue[job.variant]?.start()
}
}
@ -211,7 +211,10 @@ public final class JobRunner {
].contains(Job.Columns.behaviour)
)
.filter(Job.Columns.shouldBlock == true)
.order(Job.Columns.id)
.order(
Job.Columns.priority.desc,
Job.Columns.id
)
.fetchAll(db)
let nonblockingJobs: [Job] = try Job
.filter(
@ -221,7 +224,10 @@ public final class JobRunner {
].contains(Job.Columns.behaviour)
)
.filter(Job.Columns.shouldBlock == false)
.order(Job.Columns.id)
.order(
Job.Columns.priority.desc,
Job.Columns.id
)
.fetchAll(db)
return (blockingJobs, nonblockingJobs)
@ -260,7 +266,10 @@ public final class JobRunner {
.read { db in
return try Job
.filter(Job.Columns.behaviour == Job.Behaviour.recurringOnActive)
.order(Job.Columns.id)
.order(
Job.Columns.priority.desc,
Job.Columns.id
)
.fetchAll(db)
}
.defaulting(to: [])