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:
parent
07046db4b6
commit
345b693225
|
@ -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 */,
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 }
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 ])
|
||||
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -197,7 +197,7 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC
|
|||
threadVariant: .openGroup,
|
||||
isMessageRequest: false,
|
||||
action: .compose,
|
||||
focusInteractionId: nil,
|
||||
focusInteractionInfo: nil,
|
||||
animated: false
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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: {})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
]
|
||||
]
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 }) {
|
||||
|
|
|
@ -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)) ?? [])
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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).*,
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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"
|
||||
]))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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) }
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -17,7 +17,8 @@ public enum SNSnodeKit { // Just to make the external API nice
|
|||
],
|
||||
[
|
||||
_004_FlagMessageHashAsDeletedOrInvalid.self
|
||||
]
|
||||
],
|
||||
[] // Add job priorities
|
||||
]
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -15,7 +15,7 @@ public enum SNUIKit {
|
|||
[], // YDB Removal
|
||||
[
|
||||
_001_ThemePreferences.self
|
||||
]
|
||||
] // Add job priorities
|
||||
]
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -17,7 +17,9 @@ public enum SNUtilitiesKit { // Just to make the external API nice
|
|||
],
|
||||
[], // Other DB migrations
|
||||
[], // Legacy DB removal
|
||||
[]
|
||||
[
|
||||
_004_AddJobPriority.self
|
||||
]
|
||||
]
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
|
||||
|
|
|
@ -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: [])
|
||||
|
|
Loading…
Reference in New Issue