From d863004e6dc9b6f5c5ce86309b16ab0fd58ba817 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 11 Aug 2023 18:02:06 +1000 Subject: [PATCH] Added a setting to control community message request polling Added logic to broadcast the community message request acceptance to SOGS so we can communicate it to message request senders Fixed an issue where database setting changes wouldn't trigger a live update on a settings screen Fixed an issue where some setting toggles wouldn't animate the state change Fixed a rarw force-unwrap crash --- LibSession-Util | 2 +- Session.xcodeproj/project.pbxproj | 8 ++ Session/Conversations/ConversationVC.swift | 48 ++++---- .../Settings/ThreadSettingsViewModel.swift | 18 ++- .../Translations/de.lproj/Localizable.strings | 4 + .../Translations/en.lproj/Localizable.strings | 4 + .../Translations/es.lproj/Localizable.strings | 4 + .../Translations/fa.lproj/Localizable.strings | 4 + .../Translations/fi.lproj/Localizable.strings | 4 + .../Translations/fr.lproj/Localizable.strings | 4 + .../Translations/hi.lproj/Localizable.strings | 4 + .../Translations/hr.lproj/Localizable.strings | 4 + .../id-ID.lproj/Localizable.strings | 4 + .../Translations/it.lproj/Localizable.strings | 4 + .../Translations/ja.lproj/Localizable.strings | 4 + .../Translations/nl.lproj/Localizable.strings | 4 + .../Translations/pl.lproj/Localizable.strings | 4 + .../pt_BR.lproj/Localizable.strings | 4 + .../Translations/ru.lproj/Localizable.strings | 4 + .../Translations/si.lproj/Localizable.strings | 4 + .../Translations/sk.lproj/Localizable.strings | 4 + .../Translations/sv.lproj/Localizable.strings | 4 + .../Translations/th.lproj/Localizable.strings | 4 + .../vi-VN.lproj/Localizable.strings | 4 + .../zh-Hant.lproj/Localizable.strings | 4 + .../zh_CN.lproj/Localizable.strings | 4 + .../ConversationSettingsViewModel.swift | 32 +++++- .../NotificationSettingsViewModel.swift | 66 ++++++++--- .../Settings/PrivacySettingsViewModel.swift | 106 +++++++++++++++--- .../Shared/SessionTableViewController.swift | 9 +- Session/Shared/SessionTableViewModel.swift | 9 +- .../Shared/Types/SessionCell+Accessory.swift | 45 +++++--- .../Views/SessionCell+AccessoryView.swift | 16 ++- Session/Shared/Views/SessionCell.swift | 8 +- Session/Utilities/MockDataGenerator.swift | 9 +- SessionMessagingKit/Configuration.swift | 3 +- .../Migrations/_003_YDBToGRDBMigration.swift | 9 +- .../_005_FixDeletedMessageReadState.swift | 2 +- .../_006_FixHiddenModAdminSupport.swift | 2 +- .../_007_HomeQueryOptimisationIndexes.swift | 2 +- .../Migrations/_008_EmojiReacts.swift | 2 +- .../Migrations/_009_OpenGroupPermission.swift | 2 +- .../_011_AddPendingReadReceipts.swift | 2 +- .../Migrations/_012_AddFTSIfNeeded.swift | 2 +- .../_015_BlockCommunityMessageRequests.swift | 33 ++++++ .../Database/Models/Profile.swift | 29 ++++- .../VisibleMessage+Profile.swift | 22 +++- .../Open Groups/OpenGroupAPI.swift | 12 +- .../Protos/Generated/SNProto.swift | 14 +++ .../Protos/Generated/SessionProtos.pb.swift | 56 ++++++++- .../Generated/WebSocketResources.pb.swift | 7 ++ .../Protos/SessionProtos.proto | 29 ++--- .../MessageReceiver+VisibleMessages.swift | 1 + .../Sending & Receiving/MessageSender.swift | 3 +- .../SessionUtil+Contacts.swift | 3 +- .../Config Handling/SessionUtil+Shared.swift | 24 ++++ .../SessionUtil+UserProfile.swift | 26 +++++ .../Database/Setting+Utilities.swift | 58 ++++++++++ .../SessionThreadViewModel.swift | 6 +- .../Utilities/Preferences.swift | 3 + .../Utilities/ProfileManager.swift | 7 ++ .../Configs/ConfigUserProfileSpec.swift | 15 +++ .../ShareNavController.swift | 4 +- .../Combine/Publisher+Utilities.swift | 17 +++ .../Database/Models/Setting.swift | 48 +++++++- 65 files changed, 766 insertions(+), 141 deletions(-) create mode 100644 SessionMessagingKit/Database/Migrations/_015_BlockCommunityMessageRequests.swift create mode 100644 SessionMessagingKit/SessionUtil/Database/Setting+Utilities.swift diff --git a/LibSession-Util b/LibSession-Util index d8f07fa92..e3ccf29db 160000 --- a/LibSession-Util +++ b/LibSession-Util @@ -1 +1 @@ -Subproject commit d8f07fa92c12c5c2409774e03e03395d7847d1c2 +Subproject commit e3ccf29db08aaf0b9bb6bbe72ae5967cd183a78d diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index cac7103f7..e0f3f7efe 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -531,6 +531,8 @@ FD1A94FB2900D1C2000D73D3 /* PersistableRecord+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1A94FA2900D1C2000D73D3 /* PersistableRecord+Utilities.swift */; }; FD1A94FE2900D2EA000D73D3 /* PersistableRecordUtilitiesSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1A94FD2900D2EA000D73D3 /* PersistableRecordUtilitiesSpec.swift */; }; FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */; }; + FD1D732A2A85AA2000E3F410 /* Setting+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1D73292A85AA2000E3F410 /* Setting+Utilities.swift */; }; + FD1D732E2A86114600E3F410 /* _015_BlockCommunityMessageRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1D732D2A86114600E3F410 /* _015_BlockCommunityMessageRequests.swift */; }; FD23CE1B2A651E6D0000B97C /* NetworkType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE1A2A651E6D0000B97C /* NetworkType.swift */; }; FD23CE1F2A65269C0000B97C /* Crypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE1E2A65269C0000B97C /* Crypto.swift */; }; FD23CE222A661D000000B97C /* OpenGroupAPI+Crypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23CE212A661D000000B97C /* OpenGroupAPI+Crypto.swift */; }; @@ -1689,6 +1691,8 @@ FD1A94FA2900D1C2000D73D3 /* PersistableRecord+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistableRecord+Utilities.swift"; sourceTree = ""; }; FD1A94FD2900D2EA000D73D3 /* PersistableRecordUtilitiesSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistableRecordUtilitiesSpec.swift; sourceTree = ""; }; FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationBar+Utilities.swift"; sourceTree = ""; }; + FD1D73292A85AA2000E3F410 /* Setting+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Setting+Utilities.swift"; sourceTree = ""; }; + FD1D732D2A86114600E3F410 /* _015_BlockCommunityMessageRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _015_BlockCommunityMessageRequests.swift; sourceTree = ""; }; FD23CE1A2A651E6D0000B97C /* NetworkType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkType.swift; sourceTree = ""; }; FD23CE1E2A65269C0000B97C /* Crypto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Crypto.swift; sourceTree = ""; }; FD23CE212A661D000000B97C /* OpenGroupAPI+Crypto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OpenGroupAPI+Crypto.swift"; sourceTree = ""; }; @@ -3618,6 +3622,7 @@ FD368A6729DE8F9B000DBF1E /* _012_AddFTSIfNeeded.swift */, FD8ECF7C2934293A00C0D1BB /* _013_SessionUtilChanges.swift */, FD778B6329B189FF001BAC6B /* _014_GenerateInitialUserConfigDumps.swift */, + FD1D732D2A86114600E3F410 /* _015_BlockCommunityMessageRequests.swift */, ); path = Migrations; sourceTree = ""; @@ -3763,6 +3768,7 @@ FD2B4B022949886900AB4848 /* Database */ = { isa = PBXGroup; children = ( + FD1D73292A85AA2000E3F410 /* Setting+Utilities.swift */, FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */, ); path = Database; @@ -5914,12 +5920,14 @@ C352A35B2557824E00338F3E /* AttachmentUploadJob.swift in Sources */, FD2959922A4417A900888A17 /* PreparedSendData.swift in Sources */, FD5C7305284F0FF30029977D /* MessageReceiver+VisibleMessages.swift in Sources */, + FD1D732E2A86114600E3F410 /* _015_BlockCommunityMessageRequests.swift in Sources */, FD432437299DEA38008A0213 /* TypeConversion+Utilities.swift in Sources */, FD2B4B042949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift in Sources */, FD09797027FA6FF300936362 /* Profile.swift in Sources */, FD245C56285065EA00B966DD /* SNProto.swift in Sources */, FD09798B27FD1CFE00936362 /* Capability.swift in Sources */, C3BBE0C72554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift in Sources */, + FD1D732A2A85AA2000E3F410 /* Setting+Utilities.swift in Sources */, FD17D79C27F40B2E00122BE0 /* SMKLegacy.swift in Sources */, FD09798127FCFEE800936362 /* SessionThread.swift in Sources */, FD09C5EA282A1BB2000CE219 /* ThreadTypingIndicator.swift in Sources */, diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index fa5656fa1..523cdd884 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -208,17 +208,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers }() private lazy var emptyStateLabel: UILabel = { - let text: String = String( - format: { - switch (viewModel.threadData.threadIsNoteToSelf, viewModel.threadData.canWrite) { - case (true, _): return "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF".localized() - case (_, false): return "CONVERSATION_EMPTY_STATE_READ_ONLY".localized() - default: return "CONVERSATION_EMPTY_STATE".localized() - } - }(), - viewModel.threadData.displayName - ) - + let text: String = emptyStateText(for: viewModel.threadData) let result: UILabel = UILabel() result.accessibilityLabel = "Empty state label" result.translatesAutoresizingMaskIntoConstraints = false @@ -698,6 +688,24 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers self.viewModel.onInteractionChange = nil } + private func emptyStateText(for threadData: SessionThreadViewModel) -> String { + return String( + format: { + switch (threadData.threadIsNoteToSelf, threadData.canWrite) { + case (true, _): return "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF".localized() + case (_, false): + return (threadData.profile?.blocksCommunityMessageRequests == true ? + "COMMUNITY_MESSAGE_REQUEST_DISABLED_EMPTY_STATE".localized() : + "CONVERSATION_EMPTY_STATE_READ_ONLY".localized() + ) + + default: return "CONVERSATION_EMPTY_STATE".localized() + } + }(), + threadData.displayName + ) + } + private func handleThreadUpdates(_ updatedThreadData: SessionThreadViewModel, initialLoad: Bool = false) { // Ensure the first load or a load when returning from a child screen runs without animations (if // we don't do this the cells will animate in from a frame of CGRect.zero or have a buggy transition) @@ -738,17 +746,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers ) // Update the empty state - let text: String = String( - format: { - switch (updatedThreadData.threadIsNoteToSelf, updatedThreadData.canWrite) { - case (true, _): return "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF".localized() - case (_, false): return "CONVERSATION_EMPTY_STATE_READ_ONLY".localized() - default: return "CONVERSATION_EMPTY_STATE".localized() - } - }(), - updatedThreadData.displayName - ) - + let text: String = emptyStateText(for: updatedThreadData) emptyStateLabel.attributedText = NSAttributedString(string: text) .adding( attributes: [.font: UIFont.boldSystemFont(ofSize: Values.verySmallFontSize)], @@ -791,8 +789,10 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers updatedThreadData.threadRequiresApproval == true ) self?.messageRequestStackView.isHidden = ( - updatedThreadData.threadIsMessageRequest == false && - updatedThreadData.threadRequiresApproval == false + !updatedThreadData.canWrite || ( + updatedThreadData.threadIsMessageRequest == false && + updatedThreadData.threadRequiresApproval == false + ) ) self?.messageRequestBackgroundView.isHidden = (self?.messageRequestStackView.isHidden == true) self?.messageRequestDescriptionLabelBottomConstraint?.constant = (updatedThreadData.threadRequiresApproval == true ? -4 : -20) diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 40792b3fc..07251bcc5 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -178,6 +178,7 @@ class ThreadSettingsViewModel: SessionTableViewModel [SectionModel] in + .trackingConstantRegion { [weak self] db -> State in + State( + trimOpenGroupMessagesOlderThanSixMonths: db[.trimOpenGroupMessagesOlderThanSixMonths], + shouldAutoPlayConsecutiveAudioMessages: db[.shouldAutoPlayConsecutiveAudioMessages] + ) + } + .removeDuplicates() + .handleEvents(didFail: { SNLog("[ConversationSettingsViewModel] Observation failed with error: \($0)") }) + .publisher(in: Storage.shared) + .withPrevious() + .map { (previous: State?, current: State) -> [SectionModel] in return [ SectionModel( model: .messageTrimming, @@ -55,7 +70,11 @@ class ConversationSettingsViewModel: SessionTableViewModel { +class NotificationSettingsViewModel: SessionTableViewModel { // MARK: - Config public enum Section: SessionTableSection { @@ -31,7 +31,7 @@ class NotificationSettingsViewModel: SessionTableViewModel [SectionModel] in - let notificationSound: Preferences.Sound = db[.defaultNotificationSound] - .defaulting(to: Preferences.Sound.defaultNotificationSound) - let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] - .defaulting(to: Preferences.NotificationPreviewType.defaultPreviewType) - + .trackingConstantRegion { db -> State in + State( + isUsingFullAPNs: false, // Set later the the data flow + notificationSound: db[.defaultNotificationSound] + .defaulting(to: Preferences.Sound.defaultNotificationSound), + playNotificationSoundInForeground: db[.playNotificationSoundInForeground], + previewType: db[.preferencesNotificationPreviewType] + .defaulting(to: Preferences.NotificationPreviewType.defaultPreviewType) + ) + } + .removeDuplicates() + .handleEvents(didFail: { SNLog("[NotificationSettingsViewModel] Observation failed with error: \($0)") }) + .publisher(in: Storage.shared) + .manualRefreshFrom(forcedRefresh) + .map { dbState -> State in + State( + isUsingFullAPNs: UserDefaults.standard[.isUsingFullAPNs], + notificationSound: dbState.notificationSound, + playNotificationSoundInForeground: dbState.playNotificationSoundInForeground, + previewType: dbState.previewType + ) + } + .withPrevious() + .map { (previous: State?, current: State) -> [SectionModel] in return [ SectionModel( model: .strategy, @@ -68,20 +93,24 @@ class NotificationSettingsViewModel: SessionTableViewModel [SectionModel] in + .trackingConstantRegion { [weak self] db -> State in + State( + isScreenLockEnabled: db[.isScreenLockEnabled], + checkForCommunityMessageRequests: db[.checkForCommunityMessageRequests], + areReadReceiptsEnabled: db[.areReadReceiptsEnabled], + typingIndicatorsEnabled: db[.typingIndicatorsEnabled], + areLinkPreviewsEnabled: db[.areLinkPreviewsEnabled], + areCallsEnabled: db[.areCallsEnabled] + ) + } + .removeDuplicates() + .handleEvents(didFail: { SNLog("[PrivacySettingsViewModel] Observation failed with error: \($0)") }) + .publisher(in: Storage.shared) + .withPrevious() + .map { (previous: State?, current: State) -> [SectionModel] in return [ SectionModel( model: .screenSecurity, @@ -96,7 +122,13 @@ class PrivacySettingsViewModel: SessionTableViewModel 100 } // Prevent too many changes from causing performance issues ) { [weak self] updatedData in self?.viewModel.updateTableData(updatedData) @@ -339,6 +339,7 @@ class SessionTableViewController { Just(nil).eraseToAnyPublisher() } open var rightNavItems: AnyPublisher<[NavItem]?, Never> { Just(nil).eraseToAnyPublisher() } + private let _forcedRefresh: PassthroughSubject = PassthroughSubject() + lazy var forcedRefresh: AnyPublisher = _forcedRefresh + .shareReplay(0) private let _showToast: PassthroughSubject<(String, ThemeValue), Never> = PassthroughSubject() lazy var showToast: AnyPublisher<(String, ThemeValue), Never> = _showToast .shareReplay(0) @@ -62,6 +65,10 @@ class SessionTableViewModel( for viewModel: SessionTableViewModel ) -> AnyPublisher<(Output, StagedChangeset), Failure> where Output == [ArraySection>] { diff --git a/Session/Shared/Types/SessionCell+Accessory.swift b/Session/Shared/Types/SessionCell+Accessory.swift index af9d617eb..4bacade56 100644 --- a/Session/Shared/Types/SessionCell+Accessory.swift +++ b/Session/Shared/Types/SessionCell+Accessory.swift @@ -394,19 +394,30 @@ extension SessionCell.Accessory { extension SessionCell.Accessory { public enum DataSource: Hashable, Equatable { - case boolValue(Bool) + case boolValue(key: String, value: Bool, oldValue: Bool) case dynamicString(() -> String?) - case userDefaults(UserDefaults, key: String) - case settingBool(key: Setting.BoolKey) + + static func boolValue(_ value: Bool, oldValue: Bool) -> DataSource { + return .boolValue(key: "", value: value, oldValue: oldValue) + } + + static func boolValue(key: Setting.BoolKey, value: Bool, oldValue: Bool) -> DataSource { + return .boolValue(key: key.rawValue, value: value, oldValue: oldValue) + } // MARK: - Convenience public var currentBoolValue: Bool { switch self { - case .boolValue(let value): return value + case .boolValue(_, let value, _): return value case .dynamicString: return false - case .userDefaults(let defaults, let key): return defaults.bool(forKey: key) - case .settingBool(let key): return Storage.shared[key] + } + } + + public var oldBoolValue: Bool { + switch self { + case .boolValue(_, _, let oldValue): return oldValue + default: return false } } @@ -421,27 +432,27 @@ extension SessionCell.Accessory { public func hash(into hasher: inout Hasher) { switch self { - case .boolValue(let value): value.hash(into: &hasher) + case .boolValue(let key, let value, let oldValue): + key.hash(into: &hasher) + value.hash(into: &hasher) + oldValue.hash(into: &hasher) + case .dynamicString(let generator): generator().hash(into: &hasher) - case .userDefaults(_, let key): key.hash(into: &hasher) - case .settingBool(let key): key.hash(into: &hasher) } } public static func == (lhs: DataSource, rhs: DataSource) -> Bool { switch (lhs, rhs) { - case (.boolValue(let lhsValue), .boolValue(let rhsValue)): - return (lhsValue == rhsValue) + case (.boolValue(let lhsKey, let lhsValue, let lhsOldValue), .boolValue(let rhsKey, let rhsValue, let rhsOldValue)): + return ( + lhsKey == rhsKey && + lhsValue == rhsValue && + lhsOldValue == rhsOldValue + ) case (.dynamicString(let lhsGenerator), .dynamicString(let rhsGenerator)): return (lhsGenerator() == rhsGenerator()) - case (.userDefaults(_, let lhsKey), .userDefaults(_, let rhsKey)): - return (lhsKey == rhsKey) - - case (.settingBool(let lhsKey), .settingBool(let rhsKey)): - return (lhsKey == rhsKey) - default: return false } } diff --git a/Session/Shared/Views/SessionCell+AccessoryView.swift b/Session/Shared/Views/SessionCell+AccessoryView.swift index 44c81b9eb..39ca4344a 100644 --- a/Session/Shared/Views/SessionCell+AccessoryView.swift +++ b/Session/Shared/Views/SessionCell+AccessoryView.swift @@ -277,7 +277,8 @@ extension SessionCell { public func update( with accessory: Accessory?, tintColor: ThemeValue, - isEnabled: Bool + isEnabled: Bool, + isManualReload: Bool ) { guard let accessory: Accessory = accessory else { return } @@ -356,10 +357,15 @@ extension SessionCell { fixedWidthConstraint.isActive = true toggleSwitchConstraints.forEach { $0.isActive = true } - let newValue: Bool = dataSource.currentBoolValue - - if newValue != toggleSwitch.isOn { - toggleSwitch.setOn(newValue, animated: true) + if !isManualReload { + toggleSwitch.setOn(dataSource.oldBoolValue, animated: false) + + // Dispatch so the cell reload doesn't conflict with the setting change animation + if dataSource.oldBoolValue != dataSource.currentBoolValue { + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { [weak toggleSwitch] in + toggleSwitch?.setOn(dataSource.currentBoolValue, animated: true) + } + } } case .dropDown(let dataSource, let accessibility): diff --git a/Session/Shared/Views/SessionCell.swift b/Session/Shared/Views/SessionCell.swift index 912fb37a9..1b2b8630e 100644 --- a/Session/Shared/Views/SessionCell.swift +++ b/Session/Shared/Views/SessionCell.swift @@ -313,7 +313,7 @@ public class SessionCell: UITableViewCell { botSeparator.isHidden = true } - public func update(with info: Info) { + public func update(with info: Info, isManualReload: Bool = false) { interactionMode = (info.title?.interaction ?? .none) shouldHighlightTitle = (info.title?.interaction != .copy) titleExtraView = info.title?.extraViewGenerator?() @@ -332,7 +332,8 @@ public class SessionCell: UITableViewCell { leftAccessoryView.update( with: info.leftAccessory, tintColor: info.styling.tintColor, - isEnabled: info.isEnabled + isEnabled: info.isEnabled, + isManualReload: isManualReload ) titleStackView.isHidden = (info.title == nil && info.subtitle == nil) titleLabel.isUserInteractionEnabled = (info.title?.interaction == .copy) @@ -356,7 +357,8 @@ public class SessionCell: UITableViewCell { rightAccessoryView.update( with: info.rightAccessory, tintColor: info.styling.tintColor, - isEnabled: info.isEnabled + isEnabled: info.isEnabled, + isManualReload: isManualReload ) contentStackViewLeadingConstraint.isActive = (info.styling.alignment == .leading) diff --git a/Session/Utilities/MockDataGenerator.swift b/Session/Utilities/MockDataGenerator.swift index 1cbf94fbe..d80ea168b 100644 --- a/Session/Utilities/MockDataGenerator.swift +++ b/Session/Utilities/MockDataGenerator.swift @@ -99,7 +99,8 @@ enum MockDataGenerator { .compactMap { _ in stringContent.randomElement(using: &dmThreadRandomGenerator) } .joined(), lastNameUpdate: Date().timeIntervalSince1970, - lastProfilePictureUpdate: Date().timeIntervalSince1970 + lastProfilePictureUpdate: Date().timeIntervalSince1970, + lastBlocksCommunityMessageRequests: 0 ) .saved(db) @@ -180,7 +181,8 @@ enum MockDataGenerator { .compactMap { _ in stringContent.randomElement(using: &cgThreadRandomGenerator) } .joined(), lastNameUpdate: Date().timeIntervalSince1970, - lastProfilePictureUpdate: Date().timeIntervalSince1970 + lastProfilePictureUpdate: Date().timeIntervalSince1970, + lastBlocksCommunityMessageRequests: 0 ) .saved(db) @@ -310,7 +312,8 @@ enum MockDataGenerator { .compactMap { _ in stringContent.randomElement(using: &ogThreadRandomGenerator) } .joined(), lastNameUpdate: Date().timeIntervalSince1970, - lastProfilePictureUpdate: Date().timeIntervalSince1970 + lastProfilePictureUpdate: Date().timeIntervalSince1970, + lastBlocksCommunityMessageRequests: 0 ) .saved(db) diff --git a/SessionMessagingKit/Configuration.swift b/SessionMessagingKit/Configuration.swift index 0aa4f5d37..8522a4276 100644 --- a/SessionMessagingKit/Configuration.swift +++ b/SessionMessagingKit/Configuration.swift @@ -37,7 +37,8 @@ public enum SNMessagingKit: MigratableTarget { // Just to make the external API (Features.useSharedUtilForUserConfig(db) ? _014_GenerateInitialUserConfigDumps.self : (nil as Migration.Type?) - ) + ), + _015_BlockCommunityMessageRequests.self ].compactMap { $0 } ] ) diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 8918c1c9b..9f0af5346 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -422,7 +422,8 @@ enum _003_YDBToGRDBMigration: Migration { profilePictureUrl: legacyContact.profilePictureURL, profilePictureFileName: legacyContact.profilePictureFileName, profileEncryptionKey: legacyContact.profileEncryptionKey?.keyData, - lastProfilePictureUpdate: 0 + lastProfilePictureUpdate: 0, + lastBlocksCommunityMessageRequests: 0 ).migrationSafeInsert(db) /// **Note:** The blow "shouldForce" flags are here to allow us to avoid having to run legacy migrations they @@ -645,7 +646,8 @@ enum _003_YDBToGRDBMigration: Migration { id: profileId, name: profileId, lastNameUpdate: 0, - lastProfilePictureUpdate: 0 + lastProfilePictureUpdate: 0, + lastBlocksCommunityMessageRequests: 0 ).migrationSafeSave(db) } @@ -1059,7 +1061,8 @@ enum _003_YDBToGRDBMigration: Migration { id: quotedMessage.authorId, name: quotedMessage.authorId, lastNameUpdate: 0, - lastProfilePictureUpdate: 0 + lastProfilePictureUpdate: 0, + lastBlocksCommunityMessageRequests: 0 ).migrationSafeSave(db) } diff --git a/SessionMessagingKit/Database/Migrations/_005_FixDeletedMessageReadState.swift b/SessionMessagingKit/Database/Migrations/_005_FixDeletedMessageReadState.swift index 65e68507c..235a217d3 100644 --- a/SessionMessagingKit/Database/Migrations/_005_FixDeletedMessageReadState.swift +++ b/SessionMessagingKit/Database/Migrations/_005_FixDeletedMessageReadState.swift @@ -9,7 +9,7 @@ enum _005_FixDeletedMessageReadState: Migration { static let target: TargetMigrations.Identifier = .messagingKit static let identifier: String = "FixDeletedMessageReadState" static let needsConfigSync: Bool = false - static let minExpectedRunDuration: TimeInterval = 0.1 + static let minExpectedRunDuration: TimeInterval = 0.01 static func migrate(_ db: Database) throws { _ = try Interaction diff --git a/SessionMessagingKit/Database/Migrations/_006_FixHiddenModAdminSupport.swift b/SessionMessagingKit/Database/Migrations/_006_FixHiddenModAdminSupport.swift index c1097eb94..b746c6362 100644 --- a/SessionMessagingKit/Database/Migrations/_006_FixHiddenModAdminSupport.swift +++ b/SessionMessagingKit/Database/Migrations/_006_FixHiddenModAdminSupport.swift @@ -10,7 +10,7 @@ enum _006_FixHiddenModAdminSupport: Migration { static let target: TargetMigrations.Identifier = .messagingKit static let identifier: String = "FixHiddenModAdminSupport" static let needsConfigSync: Bool = false - static let minExpectedRunDuration: TimeInterval = 0.1 + static let minExpectedRunDuration: TimeInterval = 0.01 static func migrate(_ db: Database) throws { try db.alter(table: GroupMember.self) { t in diff --git a/SessionMessagingKit/Database/Migrations/_007_HomeQueryOptimisationIndexes.swift b/SessionMessagingKit/Database/Migrations/_007_HomeQueryOptimisationIndexes.swift index b468098f7..5e53bb6ee 100644 --- a/SessionMessagingKit/Database/Migrations/_007_HomeQueryOptimisationIndexes.swift +++ b/SessionMessagingKit/Database/Migrations/_007_HomeQueryOptimisationIndexes.swift @@ -9,7 +9,7 @@ enum _007_HomeQueryOptimisationIndexes: Migration { static let target: TargetMigrations.Identifier = .messagingKit static let identifier: String = "HomeQueryOptimisationIndexes" static let needsConfigSync: Bool = false - static let minExpectedRunDuration: TimeInterval = 0.1 + static let minExpectedRunDuration: TimeInterval = 0.01 static func migrate(_ db: Database) throws { try db.create( diff --git a/SessionMessagingKit/Database/Migrations/_008_EmojiReacts.swift b/SessionMessagingKit/Database/Migrations/_008_EmojiReacts.swift index b06687dca..399dba483 100644 --- a/SessionMessagingKit/Database/Migrations/_008_EmojiReacts.swift +++ b/SessionMessagingKit/Database/Migrations/_008_EmojiReacts.swift @@ -9,7 +9,7 @@ enum _008_EmojiReacts: Migration { static let target: TargetMigrations.Identifier = .messagingKit static let identifier: String = "EmojiReacts" static let needsConfigSync: Bool = false - static let minExpectedRunDuration: TimeInterval = 0.1 + static let minExpectedRunDuration: TimeInterval = 0.01 static func migrate(_ db: Database) throws { try db.create(table: Reaction.self) { t in diff --git a/SessionMessagingKit/Database/Migrations/_009_OpenGroupPermission.swift b/SessionMessagingKit/Database/Migrations/_009_OpenGroupPermission.swift index 4f6036a2d..f4c7e8617 100644 --- a/SessionMessagingKit/Database/Migrations/_009_OpenGroupPermission.swift +++ b/SessionMessagingKit/Database/Migrations/_009_OpenGroupPermission.swift @@ -8,7 +8,7 @@ enum _009_OpenGroupPermission: Migration { static let target: TargetMigrations.Identifier = .messagingKit static let identifier: String = "OpenGroupPermission" static let needsConfigSync: Bool = false - static let minExpectedRunDuration: TimeInterval = 0.1 + static let minExpectedRunDuration: TimeInterval = 0.01 static func migrate(_ db: GRDB.Database) throws { try db.alter(table: OpenGroup.self) { t in diff --git a/SessionMessagingKit/Database/Migrations/_011_AddPendingReadReceipts.swift b/SessionMessagingKit/Database/Migrations/_011_AddPendingReadReceipts.swift index 9c2e228d5..2fb57b2cf 100644 --- a/SessionMessagingKit/Database/Migrations/_011_AddPendingReadReceipts.swift +++ b/SessionMessagingKit/Database/Migrations/_011_AddPendingReadReceipts.swift @@ -10,7 +10,7 @@ enum _011_AddPendingReadReceipts: Migration { static let target: TargetMigrations.Identifier = .messagingKit static let identifier: String = "AddPendingReadReceipts" static let needsConfigSync: Bool = false - static let minExpectedRunDuration: TimeInterval = 0.1 + static let minExpectedRunDuration: TimeInterval = 0.01 static func migrate(_ db: Database) throws { try db.create(table: PendingReadReceipt.self) { t in diff --git a/SessionMessagingKit/Database/Migrations/_012_AddFTSIfNeeded.swift b/SessionMessagingKit/Database/Migrations/_012_AddFTSIfNeeded.swift index d994b6a90..57cb66e7d 100644 --- a/SessionMessagingKit/Database/Migrations/_012_AddFTSIfNeeded.swift +++ b/SessionMessagingKit/Database/Migrations/_012_AddFTSIfNeeded.swift @@ -9,7 +9,7 @@ enum _012_AddFTSIfNeeded: Migration { static let target: TargetMigrations.Identifier = .messagingKit static let identifier: String = "AddFTSIfNeeded" static let needsConfigSync: Bool = false - static let minExpectedRunDuration: TimeInterval = 0.1 + static let minExpectedRunDuration: TimeInterval = 0.01 static func migrate(_ db: Database) throws { // Fix an issue that the fullTextSearchTable was dropped unintentionally and global search won't work. diff --git a/SessionMessagingKit/Database/Migrations/_015_BlockCommunityMessageRequests.swift b/SessionMessagingKit/Database/Migrations/_015_BlockCommunityMessageRequests.swift new file mode 100644 index 000000000..f887a6ce3 --- /dev/null +++ b/SessionMessagingKit/Database/Migrations/_015_BlockCommunityMessageRequests.swift @@ -0,0 +1,33 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +/// This migration adds a flag indicating whether a profile has indicated it is blocking community message requests +enum _015_BlockCommunityMessageRequests: Migration { + static let target: TargetMigrations.Identifier = .messagingKit + static let identifier: String = "BlockCommunityMessageRequests" + static let needsConfigSync: Bool = false + static let minExpectedRunDuration: TimeInterval = 0.01 + + static func migrate(_ db: Database) throws { + // Add the new 'Profile' properties + try db.alter(table: Profile.self) { t in + t.add(.blocksCommunityMessageRequests, .boolean) + t.add(.lastBlocksCommunityMessageRequests, .integer) + .notNull() + .defaults(to: 0) + } + + // If the user exists and the 'checkForCommunityMessageRequests' hasn't already been set then default it to "false" + if + Identity.userExists(db), + (try Setting.exists(db, id: Setting.BoolKey.checkForCommunityMessageRequests.rawValue)) == false + { + db[.checkForCommunityMessageRequests] = true + } + + Storage.update(progress: 1, for: self, in: target) // In case this is the last migration + } +} diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index cc4e4bef6..a9a6bd8af 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -27,6 +27,9 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco case profilePictureFileName case profileEncryptionKey case lastProfilePictureUpdate + + case blocksCommunityMessageRequests + case lastBlocksCommunityMessageRequests } /// The id for the user that owns the profile (Note: This could be a sessionId, a blindedId or some future variant) @@ -53,6 +56,12 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco /// The timestamp (in seconds since epoch) that the profile picture was last updated public let lastProfilePictureUpdate: TimeInterval + /// A flag indicating whether this profile has reported that it blocks community message requests + public let blocksCommunityMessageRequests: Bool? + + /// The timestamp (in seconds since epoch) that the `blocksCommunityMessageRequests` setting was last updated + public let lastBlocksCommunityMessageRequests: TimeInterval + // MARK: - Initialization public init( @@ -63,7 +72,9 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco profilePictureUrl: String? = nil, profilePictureFileName: String? = nil, profileEncryptionKey: Data? = nil, - lastProfilePictureUpdate: TimeInterval + lastProfilePictureUpdate: TimeInterval, + blocksCommunityMessageRequests: Bool? = nil, + lastBlocksCommunityMessageRequests: TimeInterval ) { self.id = id self.name = name @@ -73,6 +84,8 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco self.profilePictureFileName = profilePictureFileName self.profileEncryptionKey = profileEncryptionKey self.lastProfilePictureUpdate = lastProfilePictureUpdate + self.blocksCommunityMessageRequests = blocksCommunityMessageRequests + self.lastBlocksCommunityMessageRequests = lastBlocksCommunityMessageRequests } // MARK: - Description @@ -114,7 +127,9 @@ public extension Profile { profilePictureUrl: profilePictureUrl, profilePictureFileName: try? container.decode(String.self, forKey: .profilePictureFileName), profileEncryptionKey: profileKey, - lastProfilePictureUpdate: try container.decode(TimeInterval.self, forKey: .lastProfilePictureUpdate) + lastProfilePictureUpdate: try container.decode(TimeInterval.self, forKey: .lastProfilePictureUpdate), + blocksCommunityMessageRequests: try? container.decode(Bool.self, forKey: .blocksCommunityMessageRequests), + lastBlocksCommunityMessageRequests: try container.decode(TimeInterval.self, forKey: .lastBlocksCommunityMessageRequests) ) } @@ -129,6 +144,8 @@ public extension Profile { try container.encodeIfPresent(profilePictureFileName, forKey: .profilePictureFileName) try container.encodeIfPresent(profileEncryptionKey, forKey: .profileEncryptionKey) try container.encode(lastProfilePictureUpdate, forKey: .lastProfilePictureUpdate) + try container.encodeIfPresent(blocksCommunityMessageRequests, forKey: .blocksCommunityMessageRequests) + try container.encode(lastBlocksCommunityMessageRequests, forKey: .lastBlocksCommunityMessageRequests) } } @@ -156,7 +173,9 @@ public extension Profile { profilePictureUrl: profilePictureUrl, profilePictureFileName: nil, profileEncryptionKey: profileKey, - lastProfilePictureUpdate: sentTimestamp + lastProfilePictureUpdate: sentTimestamp, + blocksCommunityMessageRequests: (proto.hasBlocksCommunityMessageRequests ? proto.blocksCommunityMessageRequests : nil), + lastBlocksCommunityMessageRequests: (proto.hasBlocksCommunityMessageRequests ? sentTimestamp : 0) ) } @@ -242,7 +261,9 @@ public extension Profile { profilePictureUrl: nil, profilePictureFileName: nil, profileEncryptionKey: nil, - lastProfilePictureUpdate: 0 + lastProfilePictureUpdate: 0, + blocksCommunityMessageRequests: nil, + lastBlocksCommunityMessageRequests: 0 ) } diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift index 8f63ed5a9..4ad3649ac 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift @@ -10,15 +10,22 @@ public extension VisibleMessage { public let displayName: String? public let profileKey: Data? public let profilePictureUrl: String? + public let blocksCommunityMessageRequests: Bool? // MARK: - Initialization - internal init(displayName: String, profileKey: Data? = nil, profilePictureUrl: String? = nil) { + internal init( + displayName: String, + profileKey: Data? = nil, + profilePictureUrl: String? = nil, + blocksCommunityMessageRequests: Bool? = nil + ) { let hasUrlAndKey: Bool = (profileKey != nil && profilePictureUrl != nil) self.displayName = displayName self.profileKey = (hasUrlAndKey ? profileKey : nil) self.profilePictureUrl = (hasUrlAndKey ? profilePictureUrl : nil) + self.blocksCommunityMessageRequests = blocksCommunityMessageRequests } // MARK: - Proto Conversion @@ -32,7 +39,8 @@ public extension VisibleMessage { return VMProfile( displayName: displayName, profileKey: proto.profileKey, - profilePictureUrl: profileProto.profilePicture + profilePictureUrl: profileProto.profilePicture, + blocksCommunityMessageRequests: (proto.hasBlocksCommunityMessageRequests ? proto.blocksCommunityMessageRequests : nil) ) } @@ -45,6 +53,10 @@ public extension VisibleMessage { let profileProto = SNProtoLokiProfile.builder() profileProto.setDisplayName(displayName) + if let blocksCommunityMessageRequests: Bool = self.blocksCommunityMessageRequests { + dataMessageProto.setBlocksCommunityMessageRequests(blocksCommunityMessageRequests) + } + if let profileKey = profileKey, let profilePictureUrl = profilePictureUrl { dataMessageProto.setProfileKey(profileKey) profileProto.setProfilePicture(profilePictureUrl) @@ -112,10 +124,14 @@ public extension VisibleMessage { // MARK: - Conversion extension VisibleMessage.VMProfile { - init(profile: Profile) { + init( + profile: Profile, + blocksCommunityMessageRequests: Bool? + ) { self.displayName = profile.name self.profileKey = profile.profileEncryptionKey self.profilePictureUrl = profile.profilePictureUrl + self.blocksCommunityMessageRequests = blocksCommunityMessageRequests } } diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index 4cd74f821..5c5d622c0 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -109,10 +109,12 @@ public enum OpenGroupAPI { // The 'inbox' and 'outbox' only work with blinded keys so don't bother polling them if not blinded !capabilities.contains(.blind) ? [] : [ - // Inbox - (lastInboxMessageId == 0 ? - try preparedInbox(db, on: server, using: dependencies) : - try preparedInboxSince(db, id: lastInboxMessageId, on: server, using: dependencies) + // Inbox (only check the inbox if the user want's community message requests) + (!db[.checkForCommunityMessageRequests] ? nil : + (lastInboxMessageId == 0 ? + try preparedInbox(db, on: server, using: dependencies) : + try preparedInboxSince(db, id: lastInboxMessageId, on: server, using: dependencies) + ) ), // Outbox @@ -120,7 +122,7 @@ public enum OpenGroupAPI { try preparedOutbox(db, on: server, using: dependencies) : try preparedOutboxSince(db, id: lastOutboxMessageId, on: server, using: dependencies) ), - ] + ].compactMap { $0 } ) ) diff --git a/SessionMessagingKit/Protos/Generated/SNProto.swift b/SessionMessagingKit/Protos/Generated/SNProto.swift index ed766ca8d..72e4ca517 100644 --- a/SessionMessagingKit/Protos/Generated/SNProto.swift +++ b/SessionMessagingKit/Protos/Generated/SNProto.swift @@ -2497,6 +2497,9 @@ extension SNProtoDataMessageClosedGroupControlMessage.SNProtoDataMessageClosedGr if let _value = syncTarget { builder.setSyncTarget(_value) } + if hasBlocksCommunityMessageRequests { + builder.setBlocksCommunityMessageRequests(blocksCommunityMessageRequests) + } return builder } @@ -2570,6 +2573,10 @@ extension SNProtoDataMessageClosedGroupControlMessage.SNProtoDataMessageClosedGr proto.syncTarget = valueParam } + @objc public func setBlocksCommunityMessageRequests(_ valueParam: Bool) { + proto.blocksCommunityMessageRequests = valueParam + } + @objc public func build() throws -> SNProtoDataMessage { return try SNProtoDataMessage.parseProto(proto) } @@ -2646,6 +2653,13 @@ extension SNProtoDataMessageClosedGroupControlMessage.SNProtoDataMessageClosedGr return proto.hasSyncTarget } + @objc public var blocksCommunityMessageRequests: Bool { + return proto.blocksCommunityMessageRequests + } + @objc public var hasBlocksCommunityMessageRequests: Bool { + return proto.hasBlocksCommunityMessageRequests + } + private init(proto: SessionProtos_DataMessage, attachments: [SNProtoAttachmentPointer], quote: SNProtoDataMessageQuote?, diff --git a/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift b/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift index 6f209cb67..3fb72c9ad 100644 --- a/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift +++ b/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift @@ -600,7 +600,7 @@ struct SessionProtos_DataMessage { set {_uniqueStorage()._attachments = newValue} } - /// optional GroupContext group = 3; // No longer used + /// optional GroupContext group = 3; // No longer used var flags: UInt32 { get {return _storage._flags ?? 0} set {_uniqueStorage()._flags = newValue} @@ -696,6 +696,15 @@ struct SessionProtos_DataMessage { /// Clears the value of `syncTarget`. Subsequent reads from it will return its default value. mutating func clearSyncTarget() {_uniqueStorage()._syncTarget = nil} + var blocksCommunityMessageRequests: Bool { + get {return _storage._blocksCommunityMessageRequests ?? false} + set {_uniqueStorage()._blocksCommunityMessageRequests = newValue} + } + /// Returns true if `blocksCommunityMessageRequests` has been explicitly set. + var hasBlocksCommunityMessageRequests: Bool {return _storage._blocksCommunityMessageRequests != nil} + /// Clears the value of `blocksCommunityMessageRequests`. Subsequent reads from it will return its default value. + mutating func clearBlocksCommunityMessageRequests() {_uniqueStorage()._blocksCommunityMessageRequests = nil} + var unknownFields = SwiftProtobuf.UnknownStorage() enum Flags: SwiftProtobuf.Enum { @@ -1665,6 +1674,43 @@ extension SessionProtos_SharedConfigMessage.Kind: CaseIterable { #endif // swift(>=4.2) +#if swift(>=5.5) && canImport(_Concurrency) +extension SessionProtos_Envelope: @unchecked Sendable {} +extension SessionProtos_Envelope.TypeEnum: @unchecked Sendable {} +extension SessionProtos_TypingMessage: @unchecked Sendable {} +extension SessionProtos_TypingMessage.Action: @unchecked Sendable {} +extension SessionProtos_UnsendRequest: @unchecked Sendable {} +extension SessionProtos_MessageRequestResponse: @unchecked Sendable {} +extension SessionProtos_Content: @unchecked Sendable {} +extension SessionProtos_CallMessage: @unchecked Sendable {} +extension SessionProtos_CallMessage.TypeEnum: @unchecked Sendable {} +extension SessionProtos_KeyPair: @unchecked Sendable {} +extension SessionProtos_DataExtractionNotification: @unchecked Sendable {} +extension SessionProtos_DataExtractionNotification.TypeEnum: @unchecked Sendable {} +extension SessionProtos_LokiProfile: @unchecked Sendable {} +extension SessionProtos_DataMessage: @unchecked Sendable {} +extension SessionProtos_DataMessage.Flags: @unchecked Sendable {} +extension SessionProtos_DataMessage.Quote: @unchecked Sendable {} +extension SessionProtos_DataMessage.Quote.QuotedAttachment: @unchecked Sendable {} +extension SessionProtos_DataMessage.Quote.QuotedAttachment.Flags: @unchecked Sendable {} +extension SessionProtos_DataMessage.Preview: @unchecked Sendable {} +extension SessionProtos_DataMessage.Reaction: @unchecked Sendable {} +extension SessionProtos_DataMessage.Reaction.Action: @unchecked Sendable {} +extension SessionProtos_DataMessage.OpenGroupInvitation: @unchecked Sendable {} +extension SessionProtos_DataMessage.ClosedGroupControlMessage: @unchecked Sendable {} +extension SessionProtos_DataMessage.ClosedGroupControlMessage.TypeEnum: @unchecked Sendable {} +extension SessionProtos_DataMessage.ClosedGroupControlMessage.KeyPairWrapper: @unchecked Sendable {} +extension SessionProtos_ConfigurationMessage: @unchecked Sendable {} +extension SessionProtos_ConfigurationMessage.ClosedGroup: @unchecked Sendable {} +extension SessionProtos_ConfigurationMessage.Contact: @unchecked Sendable {} +extension SessionProtos_ReceiptMessage: @unchecked Sendable {} +extension SessionProtos_ReceiptMessage.TypeEnum: @unchecked Sendable {} +extension SessionProtos_AttachmentPointer: @unchecked Sendable {} +extension SessionProtos_AttachmentPointer.Flags: @unchecked Sendable {} +extension SessionProtos_SharedConfigMessage: @unchecked Sendable {} +extension SessionProtos_SharedConfigMessage.Kind: @unchecked Sendable {} +#endif // swift(>=5.5) && canImport(_Concurrency) + // MARK: - Code below here is support for the SwiftProtobuf runtime. fileprivate let _protobuf_package = "SessionProtos" @@ -2288,6 +2334,7 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa 102: .same(proto: "openGroupInvitation"), 104: .same(proto: "closedGroupControlMessage"), 105: .same(proto: "syncTarget"), + 106: .same(proto: "blocksCommunityMessageRequests"), ] fileprivate class _StorageClass { @@ -2304,6 +2351,7 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa var _openGroupInvitation: SessionProtos_DataMessage.OpenGroupInvitation? = nil var _closedGroupControlMessage: SessionProtos_DataMessage.ClosedGroupControlMessage? = nil var _syncTarget: String? = nil + var _blocksCommunityMessageRequests: Bool? = nil static let defaultInstance = _StorageClass() @@ -2323,6 +2371,7 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa _openGroupInvitation = source._openGroupInvitation _closedGroupControlMessage = source._closedGroupControlMessage _syncTarget = source._syncTarget + _blocksCommunityMessageRequests = source._blocksCommunityMessageRequests } } @@ -2366,6 +2415,7 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa case 102: try { try decoder.decodeSingularMessageField(value: &_storage._openGroupInvitation) }() case 104: try { try decoder.decodeSingularMessageField(value: &_storage._closedGroupControlMessage) }() case 105: try { try decoder.decodeSingularStringField(value: &_storage._syncTarget) }() + case 106: try { try decoder.decodeSingularBoolField(value: &_storage._blocksCommunityMessageRequests) }() default: break } } @@ -2417,6 +2467,9 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa try { if let v = _storage._syncTarget { try visitor.visitSingularStringField(value: v, fieldNumber: 105) } }() + try { if let v = _storage._blocksCommunityMessageRequests { + try visitor.visitSingularBoolField(value: v, fieldNumber: 106) + } }() } try unknownFields.traverse(visitor: &visitor) } @@ -2439,6 +2492,7 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa if _storage._openGroupInvitation != rhs_storage._openGroupInvitation {return false} if _storage._closedGroupControlMessage != rhs_storage._closedGroupControlMessage {return false} if _storage._syncTarget != rhs_storage._syncTarget {return false} + if _storage._blocksCommunityMessageRequests != rhs_storage._blocksCommunityMessageRequests {return false} return true } if !storagesAreEqual {return false} diff --git a/SessionMessagingKit/Protos/Generated/WebSocketResources.pb.swift b/SessionMessagingKit/Protos/Generated/WebSocketResources.pb.swift index 737e40ce6..2fe165044 100644 --- a/SessionMessagingKit/Protos/Generated/WebSocketResources.pb.swift +++ b/SessionMessagingKit/Protos/Generated/WebSocketResources.pb.swift @@ -218,6 +218,13 @@ extension WebSocketProtos_WebSocketMessage.TypeEnum: CaseIterable { #endif // swift(>=4.2) +#if swift(>=5.5) && canImport(_Concurrency) +extension WebSocketProtos_WebSocketRequestMessage: @unchecked Sendable {} +extension WebSocketProtos_WebSocketResponseMessage: @unchecked Sendable {} +extension WebSocketProtos_WebSocketMessage: @unchecked Sendable {} +extension WebSocketProtos_WebSocketMessage.TypeEnum: @unchecked Sendable {} +#endif // swift(>=5.5) && canImport(_Concurrency) + // MARK: - Code below here is support for the SwiftProtobuf runtime. fileprivate let _protobuf_package = "WebSocketProtos" diff --git a/SessionMessagingKit/Protos/SessionProtos.proto b/SessionMessagingKit/Protos/SessionProtos.proto index 429c10b14..55d77b18f 100644 --- a/SessionMessagingKit/Protos/SessionProtos.proto +++ b/SessionMessagingKit/Protos/SessionProtos.proto @@ -192,20 +192,21 @@ message DataMessage { optional uint32 expirationTimer = 8; } - optional string body = 1; - repeated AttachmentPointer attachments = 2; - // optional GroupContext group = 3; // No longer used - optional uint32 flags = 4; - optional uint32 expireTimer = 5; - optional bytes profileKey = 6; - optional uint64 timestamp = 7; - optional Quote quote = 8; - repeated Preview preview = 10; - optional Reaction reaction = 11; - optional LokiProfile profile = 101; - optional OpenGroupInvitation openGroupInvitation = 102; - optional ClosedGroupControlMessage closedGroupControlMessage = 104; - optional string syncTarget = 105; + optional string body = 1; + repeated AttachmentPointer attachments = 2; + // optional GroupContext group = 3; // No longer used + optional uint32 flags = 4; + optional uint32 expireTimer = 5; + optional bytes profileKey = 6; + optional uint64 timestamp = 7; + optional Quote quote = 8; + repeated Preview preview = 10; + optional Reaction reaction = 11; + optional LokiProfile profile = 101; + optional OpenGroupInvitation openGroupInvitation = 102; + optional ClosedGroupControlMessage closedGroupControlMessage = 104; + optional string syncTarget = 105; + optional bool blocksCommunityMessageRequests = 106; } message ConfigurationMessage { diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index a1a959306..4b2573c5e 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -31,6 +31,7 @@ extension MessageReceiver { db, publicKey: sender, name: profile.displayName, + blocksCommunityMessageRequests: profile.blocksCommunityMessageRequests, avatarUpdate: { guard let profilePictureUrl: String = profile.profilePictureUrl, diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 9b968bdab..747841f4e 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -436,7 +436,8 @@ public final class MessageSender { // Attach the user's profile message.profile = VisibleMessage.VMProfile( - profile: Profile.fetchOrCreateCurrentUser() + profile: Profile.fetchOrCreateCurrentUser(db), + blocksCommunityMessageRequests: !db[.checkForCommunityMessageRequests] ) if (message.profile?.displayName ?? "").isEmpty { diff --git a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Contacts.swift b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Contacts.swift index 019b19829..b6a8b86f0 100644 --- a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Contacts.swift +++ b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Contacts.swift @@ -573,7 +573,8 @@ private extension SessionUtil { count: ProfileManager.avatarAES256KeyByteLength ) ), - lastProfilePictureUpdate: (TimeInterval(latestConfigSentTimestampMs) / 1000) + lastProfilePictureUpdate: (TimeInterval(latestConfigSentTimestampMs) / 1000), + lastBlocksCommunityMessageRequests: 0 ) result[contactId] = ContactData( diff --git a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Shared.swift b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Shared.swift index 909ea9ce7..c05bc17c3 100644 --- a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Shared.swift +++ b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Shared.swift @@ -213,6 +213,30 @@ internal extension SessionUtil { return updated } + static func updatingSetting(_ db: Database, _ updated: Setting?) throws { + // Don't current support any nullable settings + guard let updatedSetting: Setting = updated else { return } + + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + // Currently the only synced setting is 'checkForCommunityMessageRequests' + switch updatedSetting.id { + case Setting.BoolKey.checkForCommunityMessageRequests.rawValue: + try SessionUtil.performAndPushChange( + db, + for: .userProfile, + publicKey: userPublicKey + ) { conf in + try SessionUtil.updateSettings( + checkForCommunityMessageRequests: updatedSetting.unsafeValue(as: Bool.self), + in: conf + ) + } + + default: break + } + } + static func kickFromConversationUIIfNeeded(removedThreadIds: [String]) { guard !removedThreadIds.isEmpty else { return } diff --git a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserProfile.swift b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserProfile.swift index ed522930e..44ed7b2b2 100644 --- a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserProfile.swift +++ b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserProfile.swift @@ -12,6 +12,10 @@ internal extension SessionUtil { Profile.Columns.profileEncryptionKey ] + static let syncedSettings: [String] = [ + Setting.BoolKey.checkForCommunityMessageRequests.rawValue + ] + // MARK: - Incoming Changes static func handleUserProfileUpdate( @@ -115,6 +119,17 @@ internal extension SessionUtil { } } + // Update settings if needed + let updatedAllowBlindedMessageRequests: Int32 = user_profile_get_blinded_msgreqs(conf) + let updatedAllowBlindedMessageRequestsBoolValue: Bool = (updatedAllowBlindedMessageRequests >= 1) + + if + updatedAllowBlindedMessageRequests >= 0 && + updatedAllowBlindedMessageRequestsBoolValue != db[.checkForCommunityMessageRequests] + { + db[.checkForCommunityMessageRequests] = updatedAllowBlindedMessageRequestsBoolValue + } + // Create a contact for the current user if needed (also force-approve the current user // in case the account got into a weird state or restored directly from a migration) let userContact: Contact = Contact.fetchOrCreate(db, id: userPublicKey) @@ -159,4 +174,15 @@ internal extension SessionUtil { user_profile_set_nts_priority(conf, priority) } + + static func updateSettings( + checkForCommunityMessageRequests: Bool? = nil, + in conf: UnsafeMutablePointer? + ) throws { + guard conf != nil else { throw SessionUtilError.nilConfigObject } + + if let blindedMessageRequests: Bool = checkForCommunityMessageRequests { + user_profile_set_blinded_msgreqs(conf, (blindedMessageRequests ? 1 : 0)) + } + } } diff --git a/SessionMessagingKit/SessionUtil/Database/Setting+Utilities.swift b/SessionMessagingKit/SessionUtil/Database/Setting+Utilities.swift new file mode 100644 index 000000000..2e178c5b1 --- /dev/null +++ b/SessionMessagingKit/SessionUtil/Database/Setting+Utilities.swift @@ -0,0 +1,58 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public extension Database { + func setAndUpdateConfig(_ key: Setting.BoolKey, to newValue: Bool) throws { + try updateConfigIfNeeded(self, key: key.rawValue, updatedSetting: self.setting(key: key, to: newValue)) + } + + func setAndUpdateConfig(_ key: Setting.DoubleKey, to newValue: Double?) throws { + try updateConfigIfNeeded(self, key: key.rawValue, updatedSetting: self.setting(key: key, to: newValue)) + } + + func setAndUpdateConfig(_ key: Setting.IntKey, to newValue: Int?) throws { + try updateConfigIfNeeded(self, key: key.rawValue, updatedSetting: self.setting(key: key, to: newValue)) + } + + func setAndUpdateConfig(_ key: Setting.StringKey, to newValue: String?) throws { + try updateConfigIfNeeded(self, key: key.rawValue, updatedSetting: self.setting(key: key, to: newValue)) + } + + func setAndUpdateConfig(_ key: Setting.EnumKey, to newValue: T?) throws { + try updateConfigIfNeeded(self, key: key.rawValue, updatedSetting: self.setting(key: key, to: newValue)) + } + + func setAndUpdateConfig(_ key: Setting.EnumKey, to newValue: T?) throws { + try updateConfigIfNeeded(self, key: key.rawValue, updatedSetting: self.setting(key: key, to: newValue)) + } + + /// Value will be stored as a timestamp in seconds since 1970 + func setAndUpdateConfig(_ key: Setting.DateKey, to newValue: Date?) throws { + try updateConfigIfNeeded(self, key: key.rawValue, updatedSetting: self.setting(key: key, to: newValue)) + } + + private func updateConfigIfNeeded( + _ db: Database, + key: String, + updatedSetting: Setting? + ) throws { + // Before we do anything custom make sure the setting should trigger a change + guard SessionUtil.syncedSettings.contains(key) else { return } + + defer { + // If we changed a column that requires a config update 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) + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + db.afterNextTransactionNestedOnce(dedupeId: SessionUtil.syncDedupeId(userPublicKey)) { db in + ConfigurationSyncJob.enqueue(db, publicKey: userPublicKey) + } + } + + try SessionUtil.updatingSetting(db, updatedSetting) + } +} diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 0ee0f5f5a..c9d0c43eb 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -104,7 +104,11 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat public var canWrite: Bool { switch threadVariant { - case .contact: return true + case .contact: + guard threadIsMessageRequest == true else { return true } + + return (profile?.blocksCommunityMessageRequests != true) + case .legacyGroup, .group: return ( currentUserIsClosedGroupMember == true && diff --git a/SessionMessagingKit/Utilities/Preferences.swift b/SessionMessagingKit/Utilities/Preferences.swift index 34a00860e..4713e8ce1 100644 --- a/SessionMessagingKit/Utilities/Preferences.swift +++ b/SessionMessagingKit/Utilities/Preferences.swift @@ -65,6 +65,9 @@ public extension Setting.BoolKey { /// Controls whether concurrent audio messages should automatically be played after the one the user starts /// playing finishes static let shouldAutoPlayConsecutiveAudioMessages: Setting.BoolKey = "shouldAutoPlayConsecutiveAudioMessages" + + /// Controls whether the device will poll for community message requests (SOGS `/inbox` endpoint) + static let checkForCommunityMessageRequests: Setting.BoolKey = "checkForCommunityMessageRequests" } public extension Setting.StringKey { diff --git a/SessionMessagingKit/Utilities/ProfileManager.swift b/SessionMessagingKit/Utilities/ProfileManager.swift index 7266b30f6..7597c683a 100644 --- a/SessionMessagingKit/Utilities/ProfileManager.swift +++ b/SessionMessagingKit/Utilities/ProfileManager.swift @@ -498,6 +498,7 @@ public struct ProfileManager { _ db: Database, publicKey: String, name: String?, + blocksCommunityMessageRequests: Bool? = nil, avatarUpdate: AvatarUpdate, sentTimestamp: TimeInterval, calledFromConfigHandling: Bool = false, @@ -516,6 +517,12 @@ public struct ProfileManager { } } + // Blocks community message requets flag + if let blocksCommunityMessageRequests: Bool = blocksCommunityMessageRequests, sentTimestamp > profile.lastBlocksCommunityMessageRequests { + profileChanges.append(Profile.Columns.blocksCommunityMessageRequests.set(to: blocksCommunityMessageRequests)) + profileChanges.append(Profile.Columns.lastBlocksCommunityMessageRequests.set(to: sentTimestamp)) + } + // Profile picture & profile key var avatarNeedsDownload: Bool = false var targetAvatarUrl: String? = nil diff --git a/SessionMessagingKitTests/LibSessionUtil/Configs/ConfigUserProfileSpec.swift b/SessionMessagingKitTests/LibSessionUtil/Configs/ConfigUserProfileSpec.swift index ce0ba7a6e..d7f08b3a1 100644 --- a/SessionMessagingKitTests/LibSessionUtil/Configs/ConfigUserProfileSpec.swift +++ b/SessionMessagingKitTests/LibSessionUtil/Configs/ConfigUserProfileSpec.swift @@ -285,6 +285,17 @@ class ConfigUserProfileSpec { ) user_profile_set_pic(conf2, p2) + user_profile_set_nts_expiry(conf2, 86200) + expect(user_profile_get_nts_expiry(conf2)).to(equal(86400)) + + expect(user_profile_get_blinded_msgreqs(conf2)).to(equal(-1)) + user_profile_set_blinded_msgreqs(conf2, 0) + expect(user_profile_get_blinded_msgreqs(conf2)).to(equal(0)) + user_profile_set_blinded_msgreqs(conf2, -1) + expect(user_profile_get_blinded_msgreqs(conf2)).to(equal(-1)) + user_profile_set_blinded_msgreqs(conf2, 1) + expect(user_profile_get_blinded_msgreqs(conf2)).to(equal(1)) + // Both have changes, so push need a push expect(config_needs_push(conf)).to(beTrue()) expect(config_needs_push(conf2)).to(beTrue()) @@ -364,6 +375,10 @@ class ConfigUserProfileSpec { .to(equal("7177657274007975696f31323334353637383930313233343536373839303132")) expect(user_profile_get_nts_priority(conf)).to(equal(9)) expect(user_profile_get_nts_priority(conf2)).to(equal(9)) + expect(user_profile_get_nts_expiry(conf)).to(equal(86400)) + expect(user_profile_get_nts_expiry(conf2)).to(equal(86400)) + expect(user_profile_get_blinded_msgreqs(conf)).to(equal(1)) + expect(user_profile_get_blinded_msgreqs(conf2)).to(equal(1)) let fakeHash4: String = "fakehash4" var cFakeHash4: [CChar] = fakeHash4.cArray.nullTerminated() diff --git a/SessionShareExtension/ShareNavController.swift b/SessionShareExtension/ShareNavController.swift index 457d5e277..c2d6c1ad6 100644 --- a/SessionShareExtension/ShareNavController.swift +++ b/SessionShareExtension/ShareNavController.swift @@ -211,11 +211,11 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { } func shareViewWasCompleted() { - extensionContext!.completeRequest(returningItems: [], completionHandler: nil) + extensionContext?.completeRequest(returningItems: [], completionHandler: nil) } func shareViewWasCancelled() { - extensionContext!.completeRequest(returningItems: [], completionHandler: nil) + extensionContext?.completeRequest(returningItems: [], completionHandler: nil) } func shareViewFailed(error: Error) { diff --git a/SessionUtilitiesKit/Combine/Publisher+Utilities.swift b/SessionUtilitiesKit/Combine/Publisher+Utilities.swift index a0985631e..c4a2dc0cb 100644 --- a/SessionUtilitiesKit/Combine/Publisher+Utilities.swift +++ b/SessionUtilitiesKit/Combine/Publisher+Utilities.swift @@ -65,6 +65,23 @@ public extension Publisher { return self.receive(on: scheduler, options: options) .eraseToAnyPublisher() } + + func manualRefreshFrom(_ refreshTrigger: some Publisher) -> AnyPublisher { + return Publishers + .CombineLatest(refreshTrigger.prepend(()).setFailureType(to: Failure.self), self) + .map { _, value in value } + .eraseToAnyPublisher() + } + + func withPrevious() -> AnyPublisher<(previous: Output?, current: Output), Failure> { + scan(Optional<(Output?, Output)>.none) { ($0?.1, $1) } + .compactMap { $0 } + .eraseToAnyPublisher() + } + + func withPrevious(_ initialPreviousValue: Output) -> AnyPublisher<(previous: Output, current: Output), Failure> { + scan((initialPreviousValue, initialPreviousValue)) { ($0.1, $1) }.eraseToAnyPublisher() + } } // MARK: - Convenience diff --git a/SessionUtilitiesKit/Database/Models/Setting.swift b/SessionUtilitiesKit/Database/Models/Setting.swift index 1f634dac5..163fd01b8 100644 --- a/SessionUtilitiesKit/Database/Models/Setting.swift +++ b/SessionUtilitiesKit/Database/Models/Setting.swift @@ -15,6 +15,7 @@ public struct Setting: Codable, Identifiable, FetchableRecord, PersistableRecord } public var id: String { key } + public var rawValue: Data { value } let key: String let value: Data @@ -53,7 +54,7 @@ extension Setting { self.value = Data(bytes: &targetValue, count: MemoryLayout.size(ofValue: targetValue)) } - fileprivate func value(as type: Bool.Type) -> Bool? { + public func unsafeValue(as type: Bool.Type) -> Bool? { // Note: The 'assumingMemoryBound' is essentially going to try to convert // the memory into the provided type so can result in invalid data being // returned if the type is incorrect. But it does seem safer than the 'load' @@ -189,7 +190,7 @@ public extension Database { subscript(key: Setting.BoolKey) -> Bool { get { // Default to false if it doesn't exist - (self[key.rawValue]?.value(as: Bool.self) ?? false) + (self[key.rawValue]?.unsafeValue(as: Bool.self) ?? false) } set { self[key.rawValue] = Setting(key: key.rawValue, value: newValue) } } @@ -245,4 +246,47 @@ public extension Database { ) } } + + func setting(key: Setting.BoolKey, to newValue: Bool) -> Setting? { + let result: Setting? = Setting(key: key.rawValue, value: newValue) + self[key.rawValue] = result + return result + } + + func setting(key: Setting.DoubleKey, to newValue: Double?) -> Setting? { + let result: Setting? = Setting(key: key.rawValue, value: newValue) + self[key.rawValue] = result + return result + } + + func setting(key: Setting.IntKey, to newValue: Int?) -> Setting? { + let result: Setting? = Setting(key: key.rawValue, value: newValue) + self[key.rawValue] = result + return result + } + + func setting(key: Setting.StringKey, to newValue: String?) -> Setting? { + let result: Setting? = Setting(key: key.rawValue, value: newValue) + self[key.rawValue] = result + return result + } + + func setting(key: Setting.EnumKey, to newValue: T?) -> Setting? { + let result: Setting? = Setting(key: key.rawValue, value: newValue?.rawValue) + self[key.rawValue] = result + return result + } + + func setting(key: Setting.EnumKey, to newValue: T?) -> Setting? { + let result: Setting? = Setting(key: key.rawValue, value: newValue?.rawValue) + self[key.rawValue] = result + return result + } + + /// Value will be stored as a timestamp in seconds since 1970 + func setting(key: Setting.DateKey, to newValue: Date?) -> Setting? { + let result: Setting? = Setting(key: key.rawValue, value: newValue.map { $0.timeIntervalSince1970 }) + self[key.rawValue] = result + return result + } }